From d4618b8c79ce2b85d0e348b4c026d45fd6f149a9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 20:11:15 -0400 Subject: [PATCH] fix(chat): keep the unread divider out of the gap above an unsent message A pending or failed send is stored with messageId = -(now) and sorts among the newest rows. unreadDividerBetween compares id ranges, so the gap between an incoming message and an unsent row below it passed `older <= readThrough` and drew a divider. The real boundary drew a second one, and both share the key "unread-divider", which crashed the LazyColumn (Bugsnag 6ac2ccde396a2e952063917a, 2026.9.4: two sends failed with Denied, then two incoming messages landed in the open chat). An older row with no server id (messageId < 1) no longer takes the divider. --- .../app/messenger/internal/UnreadDivider.kt | 5 +++++ .../internal/UnreadDividerPlacementTest.kt | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt index ba2f896b07..2c1340c915 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt @@ -24,6 +24,10 @@ internal val UNREAD_DIVIDER_LIFETIME = UnreadDividerLifetime.UntilClose * Compares ranges rather than matching an id, so a read-through message that was deleted or never * stored does not matter. The rows of one message share a `messageId`, so the divider never lands * inside a message. + * + * An [older] row without a server id is a pending or failed send, stored as `-(now)` and sorted + * among the newest rows, so its id says nothing about the boundary. The gap above it never takes + * the divider; otherwise a transcript would draw a second one under the same fixed key. */ internal fun unreadDividerBetween( newer: ChatListItem.ContentBubble, @@ -31,6 +35,7 @@ internal fun unreadDividerBetween( boundary: UnreadBoundary, ): Boolean { if (boundary !is UnreadBoundary.At || newer.isFromSelf) return false + if (older != null && older.messageId < 1) return false val olderId = older?.messageId ?: Long.MIN_VALUE return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt index 81c72b8213..559881f4c9 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt @@ -63,6 +63,24 @@ class UnreadDividerPlacementTest { fun `own message first after pointer`() = assertEquals(3L, dividerAbove(UnreadBoundary.At(2, 1), row(1), row(2, self = true), row(3))) + /** + * A send that is pending or failed has no server id yet: its row carries `-(now)` and sorts + * among the newest. The gap above it is not the boundary, so the transcript keeps one divider. + */ + @Test + fun `an unsent own row between unread rows draws one divider`() { + val boundary = UnreadBoundary.At(1, 2) + val unsent = row(-noon.toEpochMilliseconds(), self = true) + val newestFirst = listOf(row(3), unsent, row(2), row(1)) + + val above = newestFirst.indices.mapNotNull { i -> + val newer = newestFirst[i] + newer.messageId.takeIf { unreadDividerBetween(newer, newestFirst.getOrNull(i + 1), boundary) } + } + + assertEquals(listOf(2L), above) + } + @Test fun `resolving draws nothing`() = assertEquals(null, dividerAbove(UnreadBoundary.Resolving, row(1), row(2)))