From eb49c76b6ef9ceef77313eacb1132891fab2a490 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 6 Feb 2025 14:43:04 -0500 Subject: [PATCH] Improve unread header counting and positioning. --- .../conversation/v2/ConversationFragment.kt | 1 + .../v2/ConversationItemDecorations.kt | 55 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) 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 1f4db95494..739a8b4b10 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 @@ -895,6 +895,7 @@ class ConversationFragment : .subscribeOn(Schedulers.io()) .doOnSuccess { state -> SignalLocalMetrics.ConversationOpen.onDataLoaded() + conversationItemDecorations.selfRecipientId = Recipient.self().id conversationItemDecorations.setFirstUnreadCount(state.meta.unreadCount) colorizer.onGroupMembershipChanged(state.meta.groupMemberAcis) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt index 1164aad3d4..e52fab04f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt @@ -14,13 +14,16 @@ 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.MessageTypes import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.drawAsTopItemDecoration import org.thoughtcrime.securesms.util.layoutIn import org.thoughtcrime.securesms.util.toLocalDate import java.util.Locale +import kotlin.math.max private typealias ConversationElement = MappingModel<*> @@ -54,6 +57,8 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch unreadViewHolder?.updateForWallpaper() } + var selfRecipientId: RecipientId? = null + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { val viewHolder = parent.getChildViewHolder(view) @@ -125,7 +130,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch val state: UnreadState = unreadState if (state is UnreadState.InitialUnreadState) { - val firstUnread: ConversationMessageElement? = findFirstUnreadStartingAt(items, (state.unreadCount - 1).coerceIn(items.indices)) + val firstUnread: ConversationMessageElement? = findFirstUnreadStartingAt(items, (state.unreadCount - 1).coerceIn(items.indices), state.unreadCount) val timestamp = firstUnread?.timestamp() if (timestamp != null) { unreadState = UnreadState.CompleteUnreadState(unreadCount = state.unreadCount, firstUnreadTimestamp = timestamp) @@ -138,9 +143,12 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch unreadState = UnreadState.None break } else { - newUnreadCount++ + if ((element.conversationMessage.messageRecord as? MmsMessageRecord)?.countsTowardsUnread() == true) { + newUnreadCount++ + } + if (element.timestamp() == state.firstUnreadTimestamp) { - unreadState = state.copy(unreadCount = newUnreadCount) + unreadState = state.copy(unreadCount = max(state.unreadCount, newUnreadCount)) break } } @@ -149,15 +157,50 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch } } - private fun findFirstUnreadStartingAt(items: List, startingIndex: Int): ConversationMessageElement? { + /** + * Attempt to find the "first" unread message, searching a range of 20 items in the list starting at index `unreadCount - 1`. The + * search helps us skip over interspersed read messages like chat events that could mess up the location of the header. + */ + private fun findFirstUnreadStartingAt(items: List, startingIndex: Int, unreadCount: Int): ConversationMessageElement? { val endingIndex = (startingIndex + 20).coerceAtMost(items.lastIndex) + var targetUnread: ConversationMessageElement? = null + var runningUnreadCount = 0 + for (index in startingIndex..endingIndex) { val item = items[index] as? ConversationMessageElement if ((item?.conversationMessage?.messageRecord as? MmsMessageRecord)?.isRead == false) { - return item + targetUnread = item + runningUnreadCount++ + } + + if (runningUnreadCount >= unreadCount) { + break } } - return items[startingIndex] as? ConversationMessageElement + + return targetUnread ?: items[startingIndex] as? ConversationMessageElement + } + + /** + * Only include message that would normally count towards unread count when updating the banner while new messages + * come in while viewing the chat. + * + * Note 1: This excludes all group chat update events even though added to group is considered unread normally. The + * corner case for being freshly added to a group, viewing the conversation, receiving new messages, and with the + * header in view is corner enough. It does not warrant reading in group state to determine if an event is a group add + * for each group event encountered. + * + * Note 2: The caller should've already checked [MmsMessageRecord.isOutgoing] before calling this but some outgoing + * messages don't use the outgoing types like an outgoing group call, so filter on the [MmsMessageRecord.fromRecipient] + * here as well. + */ + private fun MmsMessageRecord.countsTowardsUnread(): Boolean { + val likelyIncoming = MessageTypes.isInboxType(this.type) || + MessageTypes.isGroupCall(this.type) || + MessageTypes.isIncomingAudioCall(this.type) || + MessageTypes.isIncomingVideoCall(this.type) + + return likelyIncoming && !MessageTypes.isGroupUpdate(this.type) && this.fromRecipient.id != selfRecipientId } private fun isFirstUnread(bindingAdapterPosition: Int): Boolean {