From 02c4bbe816864a81de9a70c5dcf80bed81759b30 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 8 Jun 2023 11:54:15 -0400 Subject: [PATCH] Add date headers to CFv2. --- .../conversation/v2/ConversationFragment.kt | 6 + .../conversation/v2/DateHeaderDecoration.kt | 149 ++++++++++++++++++ .../securesms/util/JavaTimeExtensions.kt | 8 + 3 files changed, 163 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 43b3f4a34c..ee0558d011 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -326,6 +326,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private lateinit var multiselectItemDecoration: MultiselectItemDecoration private lateinit var openableGiftItemDecoration: OpenableGiftItemDecoration private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration + private lateinit var dateHeaderDecoration: DateHeaderDecoration private var animationsAllowed = false private var actionMode: ActionMode? = null @@ -464,6 +465,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) adapter.submitList(it) { scrollToPositionDelegate.notifyListCommitted() + dateHeaderDecoration.currentItems = it if (firstRender) { firstRender = false @@ -785,6 +787,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) binding.conversationDisabledInput.setWallpaperEnabled(wallpaperEnabled) adapter.onHasWallpaperChanged(wallpaperEnabled) + dateHeaderDecoration.hasWallpaper = wallpaperEnabled } private fun presentChatColors(chatColors: ChatColors) { @@ -909,6 +912,9 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) threadHeaderMarginDecoration = ThreadHeaderMarginDecoration() binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration) + + dateHeaderDecoration = DateHeaderDecoration(hasWallpaper = args.wallpaper != null) + binding.conversationItemRecycler.addItemDecoration(dateHeaderDecoration, 0) } private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt new file mode 100644 index 0000000000..53f87af0da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.graphics.Canvas +import android.graphics.Rect +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.toLocalDate +import java.util.Locale + +private typealias ConversationElement = MappingModel<*> + +/** + * Given the same list as used by the [ConversationAdapterV2], determines where date headers should be rendered + * and manages adjusting the list accordingly. + * + * This is a converted and trimmed down version of [org.thoughtcrime.securesms.util.StickyHeaderDecoration]. + */ +class DateHeaderDecoration(hasWallpaper: Boolean = false, private val scheduleMessageMode: Boolean = false) : RecyclerView.ItemDecoration() { + + private val headerCache: MutableMap = hashMapOf() + + var currentItems: MutableList = mutableListOf() + + var hasWallpaper: Boolean = hasWallpaper + set(value) { + field = value + headerCache.values.forEach { it.updateForWallpaper() } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + + val headerHeight = if (position in currentItems.indices && hasHeader(position)) { + getHeader(parent, currentItems[position] as ConversationMessageElement).height + } else { + 0 + } + + outRect.set(0, headerHeight, 0, 0) + } + + override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val count = parent.childCount + for (layoutPosition in 0 until count) { + val child = parent.getChildAt(parent.childCount - 1 - layoutPosition) + val position = parent.getChildAdapterPosition(child) + + if (hasHeader(position)) { + val headerView = getHeader(parent, currentItems[position] as ConversationMessageElement).itemView + c.save() + val left = parent.left + val top = child.y.toInt() - headerView.height + c.translate(left.toFloat(), top.toFloat()) + headerView.draw(c) + c.restore() + } + } + } + + private fun hasHeader(position: Int): Boolean { + val model = currentItems[position] + + if (model == null || model !is ConversationMessageElement) { + return false + } + + val previousPosition = position + 1 + val previousDay: Long + if (previousPosition in currentItems.indices) { + val previousModel = currentItems[previousPosition] + if (previousModel == null || previousModel !is ConversationMessageElement) { + return true + } else { + previousDay = previousModel.toEpochDay() + } + } else { + return false + } + + return model.toEpochDay() != previousDay + } + + private fun getHeader(parent: RecyclerView, model: ConversationMessageElement): DateHeaderViewHolder { + val headerHolder: DateHeaderViewHolder = headerCache.getOrPut(model.toEpochDay()) { + val view = LayoutInflater.from(parent.context).inflate(R.layout.conversation_item_header, parent, false) + val holder = DateHeaderViewHolder(view) + holder.bind(model) + holder + } + + val headerView = headerHolder.itemView + val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) + val childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingLeft + parent.paddingRight, headerView.layoutParams.width) + val childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, headerView.layoutParams.height) + headerView.measure(childWidth, childHeight) + headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight) + return headerHolder + } + + private fun ConversationMessageElement.timestamp(): Long { + return if (scheduleMessageMode) { + (conversationMessage.messageRecord as MediaMmsMessageRecord).scheduledDate + } else { + conversationMessage.messageRecord.dateSent + } + } + + private fun ConversationMessageElement.toEpochDay(): Long { + return timestamp().toLocalDate().toEpochDay() + } + + private inner class DateHeaderViewHolder(val itemView: View) { + private val date = itemView.findViewById(R.id.text) + + val height: Int + get() = itemView.height + + fun bind(model: ConversationMessageElement) { + val dateText = DateUtils.getConversationDateHeaderString(itemView.context, Locale.getDefault(), model.timestamp()) + date.text = dateText + updateForWallpaper() + } + + fun updateForWallpaper() { + if (hasWallpaper) { + date.setBackgroundResource(R.drawable.wallpaper_bubble_background_18) + date.setTextColor(ContextCompat.getColor(itemView.context, R.color.signal_colorNeutralInverse)) + } else { + date.background = null + date.setTextColor(ContextCompat.getColor(itemView.context, R.color.signal_colorOnSurfaceVariant)) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt index 67496a3b9f..316e9f4416 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt @@ -5,6 +5,7 @@ import android.os.Build import android.text.format.DateFormat import java.time.DayOfWeek import java.time.Instant +import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime @@ -68,6 +69,13 @@ fun Long.toLocalDateTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime return LocalDateTime.ofInstant(Instant.ofEpochMilli(this), zoneId) } +/** + * Convert milliseconds to local date with provided [zoneId]. + */ +fun Long.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate { + return Instant.ofEpochMilli(this).atZone(zoneId).toLocalDate() +} + /** * Convert milliseconds to local date time with provided [zoneId]. */