From f37567065677faefbd2a866f90380b9dfde79f95 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:13:26 -0400 Subject: [PATCH 1/7] feat(router): route message links to the chat at that message /chat/{chatId}/m/{seq} matched the group invite check, which ignores everything after the id, so a group's message link opened as the bare group, and a DM id (base64url, not UUID-shaped) fell to unrouted. ChatMessage is checked before the invite and claims exactly four segments with a numeric seq. It records which id form the link used: a group link keeps the invite's gated preview and dead-link close, a DM link opens the way a DM does. /chat/{uuid} and a malformed tail still reach the invite. --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 7 ++ .../app/core/navigation/DeeplinkType.kt | 17 +++ .../flipcash/app/scanner/internal/Scanner.kt | 9 ++ .../com/flipcash/app/analytics/Conversions.kt | 1 + .../flipcash/app/router/internal/AppRouter.kt | 46 ++++++++ .../app/router/internal/AppRouterTest.kt | 111 ++++++++++++++++++ 6 files changed, 191 insertions(+) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index b95a29cd1b..985ca9ad8e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -371,6 +371,13 @@ sealed interface AppRoute : NavKey, Parcelable { // other open keeps the empty chat, because a DM on its derived id has no record either // until someone writes in it. val isGroupLink: Boolean = false, + // Open scrolled to this message (its per-chat sequence number) and flash it: a message + // link's landing. + val jumpToMessageId: Long? = null, + // The title of the chat a message link was followed from, when it was followed from + // inside the app. The conversation offers a chip back to it; a link from outside the + // app has nowhere to go back to and sets none. + val returnToTitle: String? = null, ) : Messaging, FlowRoute { override val initialStack: List get() = when { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index fd89cfbb19..0734335cd8 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -32,6 +32,22 @@ sealed interface DeeplinkType: Parcelable { */ @Serializable data class GroupChatInvite(val chatId: ChatId): DeeplinkType, Navigatable + /** + * One message in a chat — `app.flipcash.com/chat/{chatId}/m/{seq}`. + * + * [messageId] is the message's per-chat sequence number, its canonical identity together with + * [chatId]. The chat id takes the form `/chat/{id}` already accepts: a dashed UUID for a + * group, base64url for a DM. [isGroup] records which form the link used, because the two open + * differently: a group the server has no record of is a dead link, while a DM on its derived id + * has no record until someone writes in it. + */ + @Serializable + data class ChatMessage( + val chatId: ChatId, + val messageId: Long, + val isGroup: Boolean, + ) : DeeplinkType, Navigatable + /** * A tip card addressed by account id — the older `app.flipcash.com/tip/{uuid}` that links * already shared still carry. @@ -74,6 +90,7 @@ sealed interface DeeplinkType: Parcelable { is Tipcard, is Profile, is GroupChatInvite, + is ChatMessage, -> true is Login, diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 5bac531120..33a89775ab 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -142,6 +142,15 @@ internal fun Scanner() { ), ) } + // Same destination as the tapped /chat/{id}/m/{seq} link. + is DeeplinkType.ChatMessage -> listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(deeplink.chatId), + isGroupLink = deeplink.isGroup, + jumpToMessageId = deeplink.messageId, + ), + ) else -> emptyList() } if (routes.isNotEmpty()) { diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt index 1fb7603de4..85ed7b4066 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -122,6 +122,7 @@ val DeeplinkType.analytics: String is DeeplinkType.TokenInfo -> "TokenInfo" is DeeplinkType.TipChat -> "TipChat" is DeeplinkType.GroupChatInvite -> "GroupChatInvite" + is DeeplinkType.ChatMessage -> "ChatMessage" is DeeplinkType.Tipcard -> "Tipcard" is DeeplinkType.Profile -> "Profile" is DeeplinkType.EmailVerification -> "EmailVerification" diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index bca957887f..547f53f8cb 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -141,6 +141,19 @@ internal class AppRouter( ) ) + // The chat, opened scrolled to the message. A group link keeps the invite's handling + // (gated preview, dead link closes); a DM link opens the way a DM does. + is DeeplinkType.ChatMessage -> DeeplinkAction.Navigate( + listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(type.chatId), + isGroupLink = type.isGroup, + jumpToMessageId = type.messageId, + ), + ) + ) + is DeeplinkType.Tipcard -> tipCard(TipCardOwner.ById(type.userId)) is DeeplinkType.Profile -> profile(type.address) @@ -235,6 +248,9 @@ internal class AppRouter( deepLink.isEmailVerification() -> deepLink.handleEmailVerification() deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() + // Before the invite, which matches any path under a group's `/chat/{uuid}` and would + // otherwise open a group's message link as the bare group. + deepLink.isChatMessage() -> deepLink.handleChatMessage() // Checked before the profile link, which is host-gated to the apex and so cannot // match this one, but the ordering is what keeps that true if either widens. deepLink.isGroupChatInvite() -> deepLink.handleGroupChatInvite() @@ -435,6 +451,36 @@ private fun DeepLink.handleGroupChatInvite(): DeeplinkType.GroupChatInvite? { return DeeplinkType.GroupChatInvite(chatId) } +/** + * One message in a chat: `app.flipcash.com/chat/{chatId}/m/{seq}`, exactly four segments. + * + * The chat id is a dashed UUID for a group or base64url for a DM, the forms `/chat/{id}` and + * `/tip/chat/{id}` already take. A tail that doesn't fit (`/chat/{uuid}/m`, a non-numeric seq) is + * not claimed here, so a group's falls through to the invite as it did before this route existed. + */ +private fun DeepLink.isChatMessage(): Boolean = + chat.contains(pathSegments.getOrNull(0)) && + pathSegments.size == CHAT_MESSAGE_SEGMENTS && + pathSegments[2] == MESSAGE_SEGMENT && + pathSegments[3].toLongOrNull()?.let { it >= 0 } == true + +private fun DeepLink.handleChatMessage(): DeeplinkType.ChatMessage? { + val segment = pathSegments.getOrNull(1) ?: return null + val messageId = pathSegments.getOrNull(3)?.toLongOrNull() ?: return null + val isGroup = segment.lowercase().isUuidShaped() + val chatId = runCatching { + if (isGroup) { + ChatId(UUID.fromString(segment).bytes) + } else { + ChatId(segment.decodeBase64UrlSafe().toList()) + } + }.getOrNull()?.takeIf { it.bytes.isNotEmpty() } ?: return null + return DeeplinkType.ChatMessage(chatId = chatId, messageId = messageId, isGroup = isGroup) +} + +private const val CHAT_MESSAGE_SEGMENTS = 4 +private const val MESSAGE_SEGMENT = "m" + private fun DeepLink.handleTipCard(): DeeplinkType.Tipcard? { val uri = data.toUri() val userId = uri.pathSegments.getOrNull(1) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 5b1a2d1049..ff638ec98e 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -494,6 +494,117 @@ class AppRouterTest { // endregion + // region classify + dispatch — ChatMessage + + // A DM's id in the base64url form the tip chat link already carries. + private val dmSegment = Linkify.tipChatById(sampleChatId).substringAfterLast('/') + + @Test + fun `a group message link classifies as a message, not as the group's invite`() { + val type = router.classify(DeepLink("https://app.flipcash.com/chat/$groupUuid/m/42")) + assertIs(type) + assertEquals(groupChatId, type.chatId) + assertEquals(42L, type.messageId) + assertTrue(type.isGroup) + } + + @Test + fun `a group message link classifies regardless of the case the uuid was shared in`() { + val type = router.classify( + DeepLink("https://app.flipcash.com/chat/${groupUuid.toString().uppercase()}/m/7") + ) + assertIs(type) + assertEquals(groupChatId, type.chatId) + } + + @Test + fun `a DM message link classifies with its base64url chat id`() { + val type = router.classify(DeepLink("https://app.flipcash.com/chat/$dmSegment/m/3")) + assertIs(type) + assertEquals(sampleChatId, type.chatId) + assertEquals(3L, type.messageId) + assertEquals(false, type.isGroup) + } + + @Test + fun `a message link with a non-numeric or negative seq is not claimed as a message`() { + // A DM's tail has nothing else to fall to; a group's falls through to the invite, which + // ignores the tail as it did before the message route existed. + assertNull(router.classify(DeepLink("https://app.flipcash.com/chat/$dmSegment/m/abc"))) + assertNull(router.classify(DeepLink("https://app.flipcash.com/chat/$dmSegment/m/-1"))) + assertIs( + router.classify(DeepLink("https://app.flipcash.com/chat/$groupUuid/m/abc")) + ) + } + + @Test + fun `a group link without a seq still opens as the invite`() { + assertIs( + router.classify(DeepLink("https://app.flipcash.com/chat/$groupUuid/m")) + ) + assertIs( + router.classify(DeepLink("https://app.flipcash.com/chat/$groupUuid")) + ) + } + + @Test + fun `a DM link without a message is still unrouted`() { + assertNull(router.classify(DeepLink("https://app.flipcash.com/chat/$dmSegment"))) + } + + @Test + fun `a tip chat link is not swallowed by the message matcher`() { + assertIs(router.classify(DeepLink(Linkify.tipChatById(sampleChatId)))) + } + + @Test + fun `a message link wrapped by the jump redirector unwraps`() { + val target = "https://app.flipcash.com/chat/$groupUuid/m/42" + val type = router.classify( + DeepLink("https://jump.flipcash.com/#source=" + URLEncoder.encode(target, "UTF-8")) + ) + assertIs(type) + assertEquals(42L, type.messageId) + } + + @Test + fun `dispatch opens a group message link on the group, jumping to the message`() { + loggedIn() + val action = router.dispatch(DeepLink("https://app.flipcash.com/chat/$groupUuid/m/42")) + + assertIs(action) + assertIs(action.routes[0]) + val chat = action.routes[1] + assertIs(chat) + assertEquals(groupChatId, (chat.identifier as ChatIdentifier.ByChatId).chatId) + assertTrue(chat.isGroupLink) + assertEquals(42L, chat.jumpToMessageId) + assertNull(chat.returnToTitle) + } + + @Test + fun `dispatch opens a DM message link as a DM, jumping to the message`() { + loggedIn() + val action = router.dispatch(DeepLink("https://app.flipcash.com/chat/$dmSegment/m/3")) + + assertIs(action) + val chat = action.routes[1] + assertIs(chat) + assertEquals(sampleChatId, (chat.identifier as ChatIdentifier.ByChatId).chatId) + assertEquals(false, chat.isGroupLink) + assertEquals(3L, chat.jumpToMessageId) + } + + @Test + fun `dispatch sends a message link through onboarding when logged out`() { + loggedOut() + val action = router.dispatch(DeepLink("https://app.flipcash.com/chat/$groupUuid/m/42")) + assertIs(action) + assertEquals(listOf(AppRoute.OnboardingFlow()), action.routes) + } + + // endregion + // region classify + dispatch — profile links on the bare host @Test From 244209f13e0c753928aeb4e0b8edf1081c47e1a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:20:17 -0400 Subject: [PATCH 2/7] feat(messenger): land message links on the message, with a back chip A message link opens the chat scrolled to that message and flashes it, reusing the reply jump. When the message is older than anything stored, the chat pages back from its oldest stored message until the target is stored, so the transcript stays contiguous down to it. A message link tapped inside a chat jumps in place when it points at the same chat. Otherwise it opens the linked chat with a Back to {chat} chip that pops to the chat it was tapped in. The chip hides on scroll-away or send; links opened from outside the app get no chip. --- .../core/src/main/res/values/strings.xml | 2 + .../flipcash/app/messenger/ChatFlowScreen.kt | 2 +- .../app/messenger/internal/ChatViewModel.kt | 110 ++++++++++++++-- .../internal/link/LinkCardClassifier.kt | 12 ++ .../internal/screens/MessengerScreen.kt | 32 ++++- .../screens/components/MessageList.kt | 59 ++++++++- .../screens/components/ReturnToChatChip.kt | 70 +++++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 12 ++ .../internal/delegates/MessagingDelegate.kt | 39 ++++++ .../shared/chat/LoadHistoryThroughTest.kt | 118 ++++++++++++++++++ .../app/persistence/dao/ChatMessageDao.kt | 4 + .../sources/ChatMessageDataSource.kt | 4 + 12 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LoadHistoryThroughTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a4d06fc0ed..b904d2bc34 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1181,6 +1181,8 @@ Requested just now Requested yesterday Review + + Back to %1$s Members diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 151c05d206..71b003d76b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -77,7 +77,7 @@ fun ChatFlowScreen( // first frame already has what the open can draw from memory (a group's name and picture). The // steps get this instance from flowSharedViewModel: the flow's store owner is this entry's. val viewModel = hiltViewModel( - creationCallback = { factory -> factory.create(route.identifier) }, + creationCallback = { factory -> factory.create(route.identifier, route.jumpToMessageId, route.returnToTitle) }, ) // A group link the server has no record of: say so and leave, rather than sit on an empty chat diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index eeb9b3219b..05118652a8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -244,6 +244,16 @@ data class TypingConstraints( internal class ChatViewModel @AssistedInject constructor( /** The chat this view model opens on creation. Null opens nothing; tests open their own. */ @Assisted private val identifier: ChatIdentifier?, + /** + * The message this visit opens scrolled to — a message link's landing — by its per-chat + * sequence number. Null opens where any other visit does. + */ + @Assisted private val openAtMessageId: Long? = null, + /** + * The title of the chat a message link was followed from inside the app, offered as a chip + * back to it. Null for any other open. + */ + @Assisted private val returnToTitle: String? = null, private val chatCoordinator: ChatCoordinator, private val mediaUploads: ChatMediaUploads, private val e2eePolicy: E2eePolicy, @@ -280,7 +290,10 @@ internal class ChatViewModel @AssistedInject constructor( linkCardMemory: LinkCardMemory? = null, featureFlags: FeatureFlagController = NoOpFeatureFlagController, ) : BaseViewModel( - initialState = State(), + initialState = State( + openingAtMessage = openAtMessageId != null, + returnToTitle = returnToTitle, + ), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { @@ -417,6 +430,18 @@ internal class ChatViewModel @AssistedInject constructor( * it. Set alongside [jumpTarget] and cleared with it. */ val jumpBudget: Int? = null, + /** + * True while a visit opened at a message has not yet landed on it, or found it cannot. The + * list holds its unread-divider positioning until this clears, so the two don't both walk + * the transcript on open; a message that can't be reached falls back to that positioning. + */ + val openingAtMessage: Boolean = false, + /** + * The chat this visit can go back to, shown as a chip under the bar: set when a message + * link was followed here from another chat. Cleared once the reader moves on, by scrolling + * the message away or sending. + */ + val returnToTitle: String? = null, /** * Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored * READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a @@ -1029,6 +1054,12 @@ internal class ChatViewModel @AssistedInject constructor( /** The server confirmed a message the viewer sent; the read pointer has moved past everything. */ data object OwnMessageSent : Event data object JumpConsumed : Event + + /** A jump whose message is neither stored nor on the server: nothing to scroll to. */ + data object JumpUnreachable : Event + + /** The reader scrolled away from the message a link landed on; the chip back goes. */ + data object ReturnChipDismissed : Event } /** @@ -1181,6 +1212,31 @@ internal class ChatViewModel @AssistedInject constructor( * Null until the transcript has resolved them, which it has by the time their picture is * drawn — the picture is what this is reached from. */ + /** + * Follows a tapped message link inside the app. True when [uri] was one: a message in this + * chat is jumped to in place, and one in another chat is pushed over this one with a chip back + * here. False leaves [uri] to the ordinary link handling. + */ + fun followMessageLink(uri: String): Boolean { + val link = linkCardClassifier.messageLink(uri) ?: return false + val state = stateFlow.value + if (link.chatId == state.chatId) { + dispatchEvent(Event.JumpToMessage(link.messageId)) + } else { + dispatchEvent( + Event.OpenScreen( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(link.chatId), + isGroupLink = link.isGroup, + jumpToMessageId = link.messageId, + returnToTitle = state.subject?.title, + ) + ) + ) + } + return true + } + fun memberParticipant(userId: ID): ChatParticipant.TipUser? = senderProfiles.value?.get(userId.hexEncodedString()) ?.let { ChatParticipant.TipUser(userId, it) } @@ -2926,18 +2982,24 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) - // A distance the device cannot measure is a message it never stored, and no walk would - // reach it. Resolving here rather than in the list keeps the read off the composition and - // gives the walk a bound before it starts. + // Resolving here rather than in the list keeps the read off the composition and gives the + // walk a bound before it starts. See resolveJump for a message the device never stored. eventFlow.filterIsInstance() .onEach { event -> val chatId = stateFlow.value.chatId ?: return@onEach - val distance = chatCoordinator.distanceFromNewest(chatId, event.messageId) - ?: return@onEach - dispatchEvent(Event.JumpResolved(event.messageId, distance)) + resolveJump(chatId, event.messageId) } .launchIn(viewModelScope) + // A message link's landing. Started here rather than dispatched as JumpToMessage: eventFlow + // is replay-0 and the chat id is not known yet, so the request waits on the open instead. + if (openAtMessageId != null) { + viewModelScope.launch { + val chatId = stateFlow.mapNotNull { it.chatId }.first() + resolveJump(chatId, openAtMessageId) + } + } + // Read once per chat id. The list holds read reporting until this lands, so the pointer // it reads is the one the viewer arrived with. stateFlow.mapNotNull { it.chatId } @@ -3445,9 +3507,32 @@ internal class ChatViewModel @AssistedInject constructor( } } + /** + * Resolves the walk's bound for a jump to [messageId] and hands it to the list. A message the + * device never stored is paged in from the server first, back from the oldest stored message so + * the transcript stays contiguous; a link to old history is the usual case. + */ + private suspend fun resolveJump(chatId: ChatId, messageId: Long) { + val distance = chatCoordinator.distanceFromNewest(chatId, messageId) + ?: if (chatCoordinator.loadHistoryThrough(chatId, messageId)) { + chatCoordinator.distanceFromNewest(chatId, messageId) + } else { + null + } + if (distance == null) { + dispatchEvent(Event.JumpUnreachable) + } else { + dispatchEvent(Event.JumpResolved(messageId, distance)) + } + } + @AssistedFactory interface Factory { - fun create(identifier: ChatIdentifier?): ChatViewModel + fun create( + identifier: ChatIdentifier?, + openAtMessageId: Long?, + returnToTitle: String?, + ): ChatViewModel } companion object { @@ -3622,7 +3707,8 @@ internal class ChatViewModel @AssistedInject constructor( is Event.SendMessage -> { state -> val clears = lifetime == UnreadDividerLifetime.UntilSend && state.unreadBoundary is UnreadBoundary.At - if (clears) state.copy(unreadBoundary = UnreadBoundary.None) else state + val sent = state.copy(returnToTitle = null) + if (clears) sent.copy(unreadBoundary = UnreadBoundary.None) else sent } is Event.RetryMessage -> { state -> state } is Event.StagePhotos -> { state -> state } @@ -3780,7 +3866,11 @@ internal class ChatViewModel @AssistedInject constructor( is Event.JumpResolved -> { state -> state.copy(jumpTarget = event.messageId, jumpBudget = event.budget) } - Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) } + Event.JumpConsumed -> { state -> + state.copy(jumpTarget = null, jumpBudget = null, openingAtMessage = false) + } + Event.JumpUnreachable -> { state -> state.copy(openingAtMessage = false) } + Event.ReturnChipDismissed -> { state -> state.copy(returnToTitle = null) } is Event.UnreadBoundaryResolved -> { state -> state.copy( unreadBoundary = event.boundary, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 161ce2a348..526d298801 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -79,6 +79,18 @@ internal class LinkCardClassifier @Inject constructor( fragment == null && pathSegments.first().lowercase() !in SECRET_SEGMENTS + /** + * The message a tapped link points at, when it is a message link on our hosts (wrapped in a + * jump link or not). The transcript follows these itself rather than through the system, so the + * hop keeps the chat it was tapped in to come back to. + */ + fun messageLink(url: String): DeeplinkType.ChatMessage? { + val target = unwrapJumpTarget(url) ?: url + val host = runCatching { target.toUri().host }.getOrNull() ?: return null + if (host.lowercase() !in CARD_HOSTS) return null + return router.classify(DeepLink(target)) as? DeeplinkType.ChatMessage + } + private fun classify(link: DetectedUrl): LinkCard? { val target = unwrapJumpTarget(link.url) ?: link.url val host = runCatching { target.toUri().host }.getOrNull() ?: return null diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index cc699102b3..bff1ce9faf 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur @@ -39,6 +40,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -65,6 +67,7 @@ import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge import com.flipcash.app.messenger.internal.screens.components.JoinRequestsAnchor +import com.flipcash.app.messenger.internal.screens.components.ReturnToChatChip import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.getcode.theme.CodeTheme @@ -103,8 +106,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // Links a sender typed are the ones that can lead anywhere, so only the transcript asks before // leaving. The cash card goes through [uriHandler] above: it is ours, and opens directly. val trustedWebsites = LocalTrustedWebsites.current - val transcriptUriHandler = remember(context, uriHandler, trustedWebsites) { - ExternalLinkUriHandler(context, uriHandler, trustedWebsites) + // A message link is followed in the app before anything else sees it: through the system it + // would come back as a cold deeplink, which replaces the stack and loses this chat. + val transcriptUriHandler = remember(context, uriHandler, trustedWebsites, viewModel) { + val external = ExternalLinkUriHandler(context, uriHandler, trustedWebsites) + object : UriHandler { + override fun openUri(uri: String) { + if (!viewModel.followMessageLink(uri)) external.openUri(uri) + } + } } val userManager = LocalUserManager.current @@ -434,6 +444,21 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { .padding(top = CodeTheme.dimens.grid.x2), ) } + // Held through the exit for the same reason as the requests above. + var lastReturnTitle by remember { mutableStateOf(state.returnToTitle) } + state.returnToTitle?.let { lastReturnTitle = it } + AnimatedVisibility( + visible = state.returnToTitle != null, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + ReturnToChatChip( + title = lastReturnTitle.orEmpty(), + onClick = { keyboard.hideIfVisible { navigator.pop() } }, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) + } } }, bottomBar = { @@ -515,6 +540,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { webPreviewImageLoader = viewModel.webPreviewImageLoader, webLinkPreviewsEnabled = webLinkPreviewsEnabled, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, + onLandedMessageLeft = { + viewModel.dispatchEvent(ChatViewModel.Event.ReturnChipDismissed) + }, topBarBottom = barHeight, unreadBelowCount = unreadBelowCount, onUserScrolled = { viewModel.dispatchEvent(ChatViewModel.Event.UserScrolledTranscript) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index a229b1d58b..b217058b09 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -119,6 +119,7 @@ internal fun MessageList( webPreviewImageLoader: ImageLoader? = null, webLinkPreviewsEnabled: Boolean = true, onJumpConsumed: () -> Unit = {}, + onLandedMessageLeft: () -> Unit = {}, topBarBottom: Dp = 0.dp, unreadBelowCount: Int = 0, onUserScrolled: () -> Unit = {}, @@ -266,20 +267,41 @@ internal fun MessageList( // Remembered so the row keeps being handed the same reference and stays skippable. val readAttention: () -> Float = remember { { attention.value } } + // The message a jump landed on has been scrolled off screen: the reader has moved on from + // it, and a message link's chip back to where they came from goes with it. + LaunchedEffect(attentionId) { + val landed = attentionId ?: return@LaunchedEffect + val onScreen = snapshotFlow { + listState.layoutInfo.visibleItemsInfo.any { item -> + item.index < messages.itemCount && + (messages.peek(item.index) as? ChatListItem.ContentBubble)?.messageId == landed + } + } + onScreen.first { it } + onScreen.first { !it } + onLandedMessageLeft() + } + // Walking the append path, rather than PagingConfig.jumpThreshold: a jump there routes // through PageFetcher::refresh with triggerRemoteRefresh set, so every tap would fire a // RemoteMediator refresh — token = null and a fetch of the newest page — to reach a message // already in the database. Appending stays local until the PagingSource runs dry. LaunchedEffect(state.jumpTarget) { val target = state.jumpTarget ?: return@LaunchedEffect - val budget = (state.jumpBudget?.plus(JUMP_PAGE_SIZE) ?: MAX_JUMP_ITEMS) - .coerceAtMost(MAX_JUMP_ITEMS) + val budget = state.jumpBudget?.let(::walkBudget) ?: MAX_JUMP_ITEMS + // A message link's jump can resolve before the first page has laid out, and a walk + // over an empty list has no append to hint. A quote tap is past this already. + snapshotFlow { hasLoaded && listState.layoutInfo.visibleItemsInfo.isNotEmpty() } + .first { it } messages.walkUntil(budget) { indexOf(messages, target) }?.let { listState.centerItem(it) attentionId = target attentionRequest++ } + // A visit opened at a message is positioned once this lands, found or not: reads are + // released, and the unread divider's own positioning, held for it, stays out of it. + hasPositioned = true // Last, and it has to be: this clears jumpTarget, which is what this effect is keyed // on, so the coroutine is cancelled here. The flash runs in its own effect for the // same reason. @@ -291,7 +313,11 @@ internal fun MessageList( // the list walks back to it the way a jump does and puts it just under the top bar, with // the unread messages running down from it. Reads are held until this settles, so the // bottom of the transcript is not marked read by the frame that passes through it. - LaunchedEffect(state.unreadBoundary) { + // + // A visit opened at a message (a message link) goes there instead: this stands down until + // that jump lands, and takes over only if the message can't be reached. + LaunchedEffect(state.unreadBoundary, state.openingAtMessage) { + if (state.openingAtMessage) return@LaunchedEffect when (val boundary = state.unreadBoundary) { UnreadBoundary.Resolving -> return@LaunchedEffect UnreadBoundary.None -> hasPositioned = true @@ -300,8 +326,7 @@ internal fun MessageList( snapshotFlow { hasLoaded && listState.layoutInfo.visibleItemsInfo.isNotEmpty() } .first { it } if (!listState.layoutInfo.showsUnreadDivider(messages)) { - val budget = (state.unreadWalkBudget + JUMP_PAGE_SIZE) - .coerceAtMost(MAX_JUMP_ITEMS) + val budget = walkBudget(state.unreadWalkBudget) // Not reached in budget: the chat stays at the bottom, as it would have // opened without a divider. messages.walkUntil(budget) { unreadDividerIndex(messages) } @@ -897,6 +922,7 @@ private val LazyPagingItems.appendHintIndex: Int */ private suspend fun LazyPagingItems.walkUntil(budget: Int, find: () -> Int?): Int? { var index = find() + var stalls = 0 while (index == null && loadedCount <= budget) { // Touching the last loaded index is what emits the ViewportHint that drives one more // append. One page at a time: with placeholders on, itemCount is the whole chat, so hinting @@ -915,13 +941,30 @@ private suspend fun LazyPagingItems.walkUntil(budget: Int, find: ( .first { (count, append) -> count > before || append.endOfPaginationReached } .first > before } - if (progressed != true) break + // A write to the chat invalidates the PagingSource, and the hint goes to the generation it + // replaces: on open, the newest page's refresh lands just as a message link's walk starts. + // Touching again hints the new generation; only a pager that stays put is given up on. + if (progressed == false) break + if (progressed == null) { + if (++stalls == MAX_JUMP_STALLS) break + index = find() + continue + } + stalls = 0 index = find() } return index } +/** + * The rows a walk may load to cover [messages] messages. The walk counts rows, and a message can + * take more than one: a date separator, the unread divider, a bubble per part. Counting messages + * stopped a link to the oldest message of a 1,180-message chat some 30 rows short of it. + */ +private fun walkBudget(messages: Int): Int = + (messages * ROWS_PER_MESSAGE + JUMP_PAGE_SIZE).coerceAtMost(MAX_JUMP_ITEMS) + /** The presented index of the unread divider, or `null` while it is still unloaded. */ private fun unreadDividerIndex(messages: LazyPagingItems): Int? = (0 until messages.itemCount).firstOrNull { messages.peek(it)?.holdsUnreadDivider == true } @@ -1042,5 +1085,9 @@ private val NoAttention: () -> Float = { 0f } private const val NextLayoutTimeoutMs = 250L private const val JUMP_PAGE_SIZE = 50 private const val MAX_JUMP_ITEMS = 5_000 +private const val ROWS_PER_MESSAGE = 2 private const val JUMP_STEP_TIMEOUT_MS = 2_000L +/** Steps in a row that may pass without an append before a walk gives up. */ +private const val MAX_JUMP_STALLS = 3 + diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt new file mode 100644 index 0000000000..271bc63523 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme + +/** + * The way back after a message link was followed here from another chat (concept node 11478:144, + * Landing): "Back to {chat}" under the title bar. 36 high, fully rounded, white at 12%, the values + * node 11493:201 lists for it. + */ +@Composable +internal fun ReturnToChatChip( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(ChipHeight / 2) + Row( + modifier = modifier + .height(ChipHeight) + .widthIn(max = ChipMaxWidth) + .clip(shape) + .background(Color.White.copy(alpha = 0.12f)) + .clickable(onClick = onClick) + .padding(start = CodeTheme.dimens.grid.x2, end = CodeTheme.dimens.grid.x3) + .testTag("return_to_chat_chip"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource(R.string.action_backToChat, title), + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private val ChipHeight = 36.dp +private val ChipMaxWidth = 280.dp diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 99ca1bb6eb..da89a5a35e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -327,6 +327,18 @@ interface MessagingOperations { */ suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? + /** + * Stores the history of [chatId] back through [messageId], fetching older pages from the + * server until one reaches it. True once [messageId] is stored, false if the server has no + * such message or the walk gave up. + * + * Pages from the oldest stored message rather than fetching [messageId] alone: the transcript + * pages older history from its last loaded row, so a lone message stored past a gap would leave + * the gap unfilled and its distance from the newest message undercounted. A message link to + * old history is the caller. + */ + suspend fun loadHistoryThrough(chatId: ChatId, messageId: Long): Boolean + /** * Where the "N unread" divider sits in [chatId], read from the viewer's stored READ pointer. * [UnreadBoundary.None] when the viewer's own member row is not stored or nothing inbound is diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 8d67ab2b6d..b1f93e7b0c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -34,6 +34,7 @@ import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.DeleteMessageError import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.GetChatError +import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatHydrationState @@ -51,6 +52,7 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.MessageLinkPrefetch import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType +import java.nio.ByteBuffer import com.getcode.utils.trace import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -219,6 +221,33 @@ class MessagingDelegate @Inject constructor( return selfId } + override suspend fun loadHistoryThrough(chatId: ChatId, messageId: Long): Boolean { + if (messageDataSource.getMessage(chatId, messageId) != null) return true + var oldest = messageDataSource.oldestMessageId(chatId) + var loaded = 0 + while (loaded < MAX_HISTORY_LOAD) { + // Already past it: the target is older than what is stored and was never sent back. + if (oldest != null && oldest <= messageId) return false + val page = messagingController.getMessages( + chatId, + QueryOptions( + limit = HISTORY_PAGE_SIZE, + token = oldest?.toPagingToken(), + descending = true, + ), + ).getOrNull() ?: return false + if (page.isEmpty()) return false + messageDataSource.upsert(chatId, page) + loaded += page.size + if (page.any { it.messageId <= messageId }) { + return messageDataSource.getMessage(chatId, messageId) != null + } + if (page.size < HISTORY_PAGE_SIZE) return false + oldest = page.minOf { it.messageId } + } + return false + } + override suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary { val selfId = awaitReady() val readThrough = memberDataSource.getSelfReadPointerOrNull(chatId, selfId) @@ -735,5 +764,15 @@ class MessagingDelegate @Inject constructor( private companion object { const val TAG = "MessagingDelegate" + + /** The transcript's page size. */ + const val HISTORY_PAGE_SIZE = 50 + + /** The transcript's jump bound (MessageList MAX_JUMP_ITEMS): a walk this long gives up. */ + const val MAX_HISTORY_LOAD = 5_000 } } + +/** A message id as a paging token, as [ChatMessageRemoteMediator] writes it. */ +private fun Long.toPagingToken(): List = + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(this).array().toList() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LoadHistoryThroughTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LoadHistoryThroughTest.kt new file mode 100644 index 0000000000..0fc6b02d6b --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LoadHistoryThroughTest.kt @@ -0,0 +1,118 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import java.nio.ByteBuffer +import kotlinx.coroutines.test.runTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant +import org.junit.Test + +/** + * A message link to history the device never stored. The walk pages back from the oldest stored + * message, never fetching the target alone, so the transcript stays contiguous down to it. + */ +class LoadHistoryThroughTest { + + private val chatId = ChatId("aabbccdd") + + /** The server's history: ids 1..[newest]. */ + private val newest = 200L + private val stored = sortedSetOf() + private val requestedTokens = mutableListOf() + + private val messageDataSource = mockk(relaxed = true).also { source -> + coEvery { source.getMessage(chatId, any()) } answers { + val id = secondArg() + if (id in stored) message(id) else null + } + coEvery { source.oldestMessageId(chatId) } answers { stored.firstOrNull() } + coEvery { source.upsert(chatId, any()) } answers { + stored += secondArg>().map { it.messageId } + } + } + + private val messagingController = mockk().also { controller -> + val options = slot() + coEvery { controller.getMessages(chatId, capture(options), any()) } answers { + val before = options.captured.token?.let { ByteBuffer.wrap(it.toByteArray()).long } + requestedTokens += before + val from = (before ?: (newest + 1)) - 1 + Result.success( + (from downTo maxOf(1, from - options.captured.limit + 1)).map { message(it) }, + ) + } + } + + private val delegate = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = messagingController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = mockk(relaxed = true), + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + private fun message(id: Long) = ChatMessage( + messageId = id, + senderId = listOf(4, 5, 6), + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochSeconds(id), + unreadSeq = id, + ) + + @Test + fun `a stored message needs no fetch`() = runTest { + stored += 150L..200L + + assertTrue(delegate.loadHistoryThrough(chatId, 160)) + coVerify(exactly = 0) { messagingController.getMessages(any(), any(), any()) } + } + + @Test + fun `pages back from the oldest stored message until the target is stored`() = runTest { + stored += 151L..200L + + assertTrue(delegate.loadHistoryThrough(chatId, 20)) + + // Each page starts where the last one ended: no gap between the window and the target. + assertEquals(listOf(151, 101, 51), requestedTokens) + assertEquals((1L..200L).toSortedSet(), stored) + } + + @Test + fun `nothing stored pages from the newest`() = runTest { + assertTrue(delegate.loadHistoryThrough(chatId, 180)) + assertEquals(listOf(null), requestedTokens) + } + + @Test + fun `a message older than everything stored that never came back is unreachable`() = runTest { + stored += 151L..200L + + assertFalse(delegate.loadHistoryThrough(chatId, 0)) + } + + @Test + fun `a message past the newest is unreachable`() = runTest { + stored += 151L..200L + + assertFalse(delegate.loadHistoryThrough(chatId, 500)) + coVerify(exactly = 0) { messagingController.getMessages(any(), any(), any()) } + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index cbab3c6ac1..16a694275f 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -36,6 +36,10 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC LIMIT 1") suspend fun getLatest(chatIdHex: String): ChatMessageEntity? + /** The lowest message id stored for [chatIdHex], where paging back toward older history resumes. */ + @Query("SELECT MIN(message_id) FROM chat_messages WHERE chat_id_hex = :chatIdHex") + suspend fun getOldestMessageId(chatIdHex: String): Long? + /** * The newest message that still has content — tombstones skipped, and so is a message whose * content list is empty (`'[]'`; a NULL column too), such as a system notice with no text. diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index ac5a777880..0c12b7621b 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -177,6 +177,10 @@ class ChatMessageDataSource @Inject constructor( suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? = db?.chatMessageDao()?.getMessage(mapper.chatIdHex(chatId), messageId)?.let { toChatMessage(it) } + /** The lowest message id stored for [chatId], or `null` when none is. */ + suspend fun oldestMessageId(chatId: ChatId): Long? = + db?.chatMessageDao()?.getOldestMessageId(mapper.chatIdHex(chatId)) + /** How far back [messageId] sits from the newest message in [chatId], or `null` if unknown. */ suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? { val dao = db?.chatMessageDao() ?: return null From 79183dbc04ab8d92d1dbf44877a2c6846d93bbee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 14:52:34 -0400 Subject: [PATCH 3/7] fix(messenger): hold a message link's landing until the group gate settles The landing paged history as soon as the chat id was known, before the open had resolved membership or access. A link into a group the viewer cannot read fetched and stored the transcript the blur is there to withhold. The landing now waits on State.messageLinkLanding: a member, an eligible non-member of a group with listener rules, or a DM pages to the message; a withheld group or a missing chat fetches nothing and drops the open-at-message hold. A viewer who buys in from the gate is still walked to the message. --- .../app/messenger/internal/ChatViewModel.kt | 60 +++++++++++++++++-- .../internal/ChatViewModelStateTest.kt | 60 +++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 05118652a8..fa9cc4323f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -222,6 +222,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -649,6 +650,32 @@ internal class ChatViewModel @AssistedInject constructor( val obscuresTranscript: Boolean get() = isOutsideGroup && !readsFromOutside + /** + * What a message link's landing may do here, or null while that is not known yet. + * + * A link carries a chat id before the open has said what the chat is or where the viewer + * stands in it, and paging toward the message in that window would fetch a withheld + * group's transcript, the one [obscuresTranscript] is there to keep off the device. So a + * group waits on its membership and, for a non-member, on [groupAccess] settling, and + * reads as [MessageLinkLanding.Readable] only where the transcript would be fetched anyway. + */ + val messageLinkLanding: MessageLinkLanding? + get() = when { + chatNotFound -> MessageLinkLanding.Withheld + subject is ChatSubject.Group -> when (subject.isMember) { + true -> MessageLinkLanding.Readable + null -> null + false -> when (groupAccess) { + null, GroupAccess.Undetermined -> null + else -> if (readsFromOutside) MessageLinkLanding.Readable + else MessageLinkLanding.Withheld + } + } + chatType == ChatType.TIP_DM || chatType == ChatType.CONTACT_DM -> + MessageLinkLanding.Readable + else -> null + } + /** * The host's Join Requests anchor under the title bar (node 11354:70): only while someone is * waiting, and not while a message action has taken the bar over. @@ -2993,11 +3020,27 @@ internal class ChatViewModel @AssistedInject constructor( // A message link's landing. Started here rather than dispatched as JumpToMessage: eventFlow // is replay-0 and the chat id is not known yet, so the request waits on the open instead. + // Held until the gate settles (see State.messageLinkLanding). A withheld landing gives up + // the open-at-message hold, but keeps listening: a viewer who buys in from the gate can + // still be walked to the message. if (openAtMessageId != null) { - viewModelScope.launch { - val chatId = stateFlow.mapNotNull { it.chatId }.first() - resolveJump(chatId, openAtMessageId) - } + stateFlow + .mapNotNull { state -> + val chatId = state.chatId ?: return@mapNotNull null + state.messageLinkLanding?.let { chatId to it } + } + .distinctUntilChanged() + .transformWhile { landing -> + emit(landing) + landing.second != MessageLinkLanding.Readable + } + .onEach { (chatId, landing) -> + when (landing) { + MessageLinkLanding.Readable -> resolveJump(chatId, openAtMessageId) + MessageLinkLanding.Withheld -> dispatchEvent(Event.JumpUnreachable) + } + } + .launchIn(viewModelScope) } // Read once per chat id. The list holds read reporting until this lands, so the pointer @@ -3916,3 +3959,12 @@ private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State { } return copy(quickReactionStrip = strip) } + +/** See [ChatViewModel.State.messageLinkLanding]. */ +internal enum class MessageLinkLanding { + /** The transcript is the viewer's to read, so the landing pages to the message and walks there. */ + Readable, + + /** The transcript is withheld from the viewer, or the chat is gone: nothing is fetched for it. */ + Withheld, +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index fd540af6ee..5c20dc2327 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -658,4 +658,64 @@ class ChatViewModelStateTest { assertTrue(state.canSpeak) assertFalse(state.speakerBlocksReactions) } + + @Test + fun `a message link waits while the viewer's standing in a group is unknown`() { + // A chat id alone, before the open says what it is: paging now could fetch a withheld group. + assertNull(ChatViewModel.State(chatId = groupChatId).messageLinkLanding) + assertNull(ChatViewModel.State(subject = group(isMember = null)).messageLinkLanding) + assertNull(ChatViewModel.State(subject = group(isMember = false)).messageLinkLanding) + assertNull( + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Undetermined, + ).messageLinkLanding + ) + } + + @Test + fun `a message link lands where the transcript is the viewer's to read`() { + assertEquals( + MessageLinkLanding.Readable, + ChatViewModel.State(subject = group(isMember = true)).messageLinkLanding, + ) + assertEquals( + MessageLinkLanding.Readable, + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Eligible, + ).messageLinkLanding, + ) + assertEquals( + MessageLinkLanding.Readable, + ChatViewModel.State(chatType = ChatType.TIP_DM).messageLinkLanding, + ) + assertEquals( + MessageLinkLanding.Readable, + ChatViewModel.State(subject = dm, chatType = ChatType.CONTACT_DM).messageLinkLanding, + ) + } + + @Test + fun `a message link into a withheld group fetches nothing`() { + assertEquals( + MessageLinkLanding.Withheld, + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Blocked(unmet), + ).messageLinkLanding, + ) + // No listener rules: eligible to join, but nothing to read from outside. + assertEquals( + MessageLinkLanding.Withheld, + ChatViewModel.State( + subject = group(isMember = false, rules = ChatRules(emptyList(), emptyList())), + groupAccess = GroupAccess.Eligible, + ).messageLinkLanding, + ) + assertEquals( + MessageLinkLanding.Withheld, + ChatViewModel.State(chatNotFound = true).messageLinkLanding, + ) + } } From ef68330009c374d07897b9114e102e227065e726 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 15:18:21 -0400 Subject: [PATCH 4/7] fix(messenger): hold a message-link landing until the linked group resolves A link with a group id could open with the chat marked a tip DM before the group loaded, so the landing read as Readable and paged a withheld group's history. A group hydrated by id also has a null membership, which left the landing waiting forever. Carry linkedAsGroup from the route and read a null membership as outside, as the gate does. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 9 ++++- .../app/messenger/internal/ChatViewModel.kt | 21 ++++++++++-- .../internal/ChatViewModelStateTest.kt | 33 ++++++++++++++++++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 71b003d76b..651024279f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -77,7 +77,14 @@ fun ChatFlowScreen( // first frame already has what the open can draw from memory (a group's name and picture). The // steps get this instance from flowSharedViewModel: the flow's store owner is this entry's. val viewModel = hiltViewModel( - creationCallback = { factory -> factory.create(route.identifier, route.jumpToMessageId, route.returnToTitle) }, + creationCallback = { factory -> + factory.create( + route.identifier, + route.jumpToMessageId, + route.returnToTitle, + linkedAsGroup = route.jumpToMessageId != null && route.isGroupLink, + ) + }, ) // A group link the server has no record of: say so and leave, rather than sit on an empty chat diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index fa9cc4323f..d435f15981 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -255,6 +255,11 @@ internal class ChatViewModel @AssistedInject constructor( * back to it. Null for any other open. */ @Assisted private val returnToTitle: String? = null, + /** + * Whether the link that opened this visit named a group (a dashed UUID id) rather than a DM. + * See [State.linkedAsGroup]. + */ + @Assisted private val linkedAsGroup: Boolean = false, private val chatCoordinator: ChatCoordinator, private val mediaUploads: ChatMediaUploads, private val e2eePolicy: E2eePolicy, @@ -294,6 +299,7 @@ internal class ChatViewModel @AssistedInject constructor( initialState = State( openingAtMessage = openAtMessageId != null, returnToTitle = returnToTitle, + linkedAsGroup = linkedAsGroup, ), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, @@ -443,6 +449,12 @@ internal class ChatViewModel @AssistedInject constructor( * the message away or sending. */ val returnToTitle: String? = null, + /** + * Whether a message link opened this visit with a group's id. The open can mark the chat a + * tip DM before the group resolves, and a landing that believed it would page a withheld + * group's history, so [messageLinkLanding] waits on the group for such a link. + */ + val linkedAsGroup: Boolean = false, /** * Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored * READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a @@ -662,15 +674,19 @@ internal class ChatViewModel @AssistedInject constructor( val messageLinkLanding: MessageLinkLanding? get() = when { chatNotFound -> MessageLinkLanding.Withheld + // A null membership is a group hydrated by id, which the device holds no row for; + // like the gate, read it as outside (see ChatMembership.isMember). subject is ChatSubject.Group -> when (subject.isMember) { true -> MessageLinkLanding.Readable - null -> null - false -> when (groupAccess) { + null, false -> when (groupAccess) { null, GroupAccess.Undetermined -> null else -> if (readsFromOutside) MessageLinkLanding.Readable else MessageLinkLanding.Withheld } } + // The open can call a group a tip DM before the group resolves, so a link that + // named a group never reads as one. + linkedAsGroup -> null chatType == ChatType.TIP_DM || chatType == ChatType.CONTACT_DM -> MessageLinkLanding.Readable else -> null @@ -3575,6 +3591,7 @@ internal class ChatViewModel @AssistedInject constructor( identifier: ChatIdentifier?, openAtMessageId: Long?, returnToTitle: String?, + linkedAsGroup: Boolean, ): ChatViewModel } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index 5c20dc2327..d2a3df114b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -663,7 +663,6 @@ class ChatViewModelStateTest { fun `a message link waits while the viewer's standing in a group is unknown`() { // A chat id alone, before the open says what it is: paging now could fetch a withheld group. assertNull(ChatViewModel.State(chatId = groupChatId).messageLinkLanding) - assertNull(ChatViewModel.State(subject = group(isMember = null)).messageLinkLanding) assertNull(ChatViewModel.State(subject = group(isMember = false)).messageLinkLanding) assertNull( ChatViewModel.State( @@ -673,6 +672,38 @@ class ChatViewModelStateTest { ) } + @Test + fun `a message link into a group hydrated by id waits on access, not membership`() { + // A group the device holds no row for resolves with isMember = null and keeps it, so + // waiting on membership would hold the landing forever; seen on device. + assertNull(ChatViewModel.State(subject = group(isMember = null)).messageLinkLanding) + assertEquals( + MessageLinkLanding.Withheld, + ChatViewModel.State( + subject = group(isMember = null), + groupAccess = GroupAccess.Blocked(unmet), + ).messageLinkLanding, + ) + } + + @Test + fun `a group's message link waits for the group even after the open calls it a tip DM`() { + // OnTipDmDetected can mark the chat a tip DM before the group resolves; seen on device as + // a gated group's history being paged. + assertNull( + ChatViewModel.State(linkedAsGroup = true, chatType = ChatType.TIP_DM).messageLinkLanding + ) + assertEquals( + MessageLinkLanding.Withheld, + ChatViewModel.State( + linkedAsGroup = true, + chatType = ChatType.GROUP, + subject = group(isMember = false), + groupAccess = GroupAccess.Blocked(unmet), + ).messageLinkLanding, + ) + } + @Test fun `a message link lands where the transcript is the viewer's to read`() { assertEquals( From acd9441d333ceb53f21143cf7affb5fd670ebbee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 16:09:37 -0400 Subject: [PATCH 5/7] fix(messenger): dismiss the back chip only after the reader scrolls away While pages load, peek returns placeholders for rows on screen, so the landed message read as off screen and the chip was dismissed before it was seen. Wait for a drag before checking. --- .../internal/screens/components/MessageList.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index b217058b09..6b88c995ab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -5,6 +5,7 @@ import com.getcode.ui.core.addIf import kotlin.math.roundToInt import kotlinx.coroutines.launch import kotlinx.coroutines.coroutineScope +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Column import androidx.compose.animation.core.tween import androidx.compose.runtime.rememberCoroutineScope @@ -268,17 +269,18 @@ internal fun MessageList( val readAttention: () -> Float = remember { { attention.value } } // The message a jump landed on has been scrolled off screen: the reader has moved on from - // it, and a message link's chip back to where they came from goes with it. + // it, and a message link's chip back to where they came from goes with it. Only the + // reader's own drag counts: while pages load, peek answers placeholders for rows on + // screen, and the landed message reads as gone without anything having moved. LaunchedEffect(attentionId) { val landed = attentionId ?: return@LaunchedEffect - val onScreen = snapshotFlow { - listState.layoutInfo.visibleItemsInfo.any { item -> + listState.interactionSource.interactions.first { it is DragInteraction.Start } + snapshotFlow { + listState.layoutInfo.visibleItemsInfo.none { item -> item.index < messages.itemCount && (messages.peek(item.index) as? ChatListItem.ContentBubble)?.messageId == landed } - } - onScreen.first { it } - onScreen.first { !it } + }.first { it } onLandedMessageLeft() } From 66b2707eaddf2b029055edff3e29c392892a2583 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 16:09:38 -0400 Subject: [PATCH 6/7] fix(messenger): put the back chip on the composer's haze ground Over a light row such as a cash card, the flat white 12% fill left the label unreadable. Use the Join Requests anchor's ultra-thin haze and divider hairline instead. --- .../internal/screens/MessengerScreen.kt | 1 + .../screens/components/ReturnToChatChip.kt | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index bff1ce9faf..777c46043b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -456,6 +456,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ReturnToChatChip( title = lastReturnTitle.orEmpty(), onClick = { keyboard.hideIfVisible { navigator.pop() } }, + hazeState = hazeState, modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt index 271bc63523..8e0c1452b4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -25,25 +26,45 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials /** * The way back after a message link was followed here from another chat (concept node 11478:144, - * Landing): "Back to {chat}" under the title bar. 36 high, fully rounded, white at 12%, the values - * node 11493:201 lists for it. + * Landing): "Back to {chat}" under the title bar. 36 high and fully rounded, the values node + * 11493:201 lists for it. The ground is [JoinRequestsAnchor]'s — the composer's ultra-thin haze with + * the divider hairline — rather than the flat white 12% the node lists: the chip floats over the + * transcript, and over a light row (a cash card) a flat tint left the label unreadable. The flat + * tint stays as the stand-in when there is no [HazeState] to sample. */ @Composable internal fun ReturnToChatChip( title: String, onClick: () -> Unit, modifier: Modifier = Modifier, + hazeState: HazeState? = null, ) { val shape = RoundedCornerShape(ChipHeight / 2) + val ground = if (hazeState != null) { + Modifier + .hazeBlur( + HazeInput.Sources(hazeState), + HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background), + ) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.12f)) + } Row( modifier = modifier .height(ChipHeight) .widthIn(max = ChipMaxWidth) .clip(shape) - .background(Color.White.copy(alpha = 0.12f)) + .then(ground) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) .clickable(onClick = onClick) .padding(start = CodeTheme.dimens.grid.x2, end = CodeTheme.dimens.grid.x3) .testTag("return_to_chat_chip"), From b81c6a0c48d4580b2af6a10a1252a52fcbd642f2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 00:14:56 -0400 Subject: [PATCH 7/7] fix(messenger): keep the back chip until it is tapped or the chat closes Scrolling away from the landed message or sending hid the chip, so a reader who scrolled to read around the linked message lost the way back. The chip now stays for the life of the screen. --- .../app/messenger/internal/ChatViewModel.kt | 11 +++-------- .../internal/screens/MessengerScreen.kt | 3 --- .../internal/screens/components/MessageList.kt | 18 ------------------ 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index d435f15981..1331fbbd4d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -445,8 +445,8 @@ internal class ChatViewModel @AssistedInject constructor( val openingAtMessage: Boolean = false, /** * The chat this visit can go back to, shown as a chip under the bar: set when a message - * link was followed here from another chat. Cleared once the reader moves on, by scrolling - * the message away or sending. + * link was followed here from another chat. It stays for the life of the screen: the chip + * goes only by being tapped or by leaving the chat. */ val returnToTitle: String? = null, /** @@ -1100,9 +1100,6 @@ internal class ChatViewModel @AssistedInject constructor( /** A jump whose message is neither stored nor on the server: nothing to scroll to. */ data object JumpUnreachable : Event - - /** The reader scrolled away from the message a link landed on; the chip back goes. */ - data object ReturnChipDismissed : Event } /** @@ -3767,8 +3764,7 @@ internal class ChatViewModel @AssistedInject constructor( is Event.SendMessage -> { state -> val clears = lifetime == UnreadDividerLifetime.UntilSend && state.unreadBoundary is UnreadBoundary.At - val sent = state.copy(returnToTitle = null) - if (clears) sent.copy(unreadBoundary = UnreadBoundary.None) else sent + if (clears) state.copy(unreadBoundary = UnreadBoundary.None) else state } is Event.RetryMessage -> { state -> state } is Event.StagePhotos -> { state -> state } @@ -3930,7 +3926,6 @@ internal class ChatViewModel @AssistedInject constructor( state.copy(jumpTarget = null, jumpBudget = null, openingAtMessage = false) } Event.JumpUnreachable -> { state -> state.copy(openingAtMessage = false) } - Event.ReturnChipDismissed -> { state -> state.copy(returnToTitle = null) } is Event.UnreadBoundaryResolved -> { state -> state.copy( unreadBoundary = event.boundary, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 777c46043b..28bc2ca4ce 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -541,9 +541,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { webPreviewImageLoader = viewModel.webPreviewImageLoader, webLinkPreviewsEnabled = webLinkPreviewsEnabled, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, - onLandedMessageLeft = { - viewModel.dispatchEvent(ChatViewModel.Event.ReturnChipDismissed) - }, topBarBottom = barHeight, unreadBelowCount = unreadBelowCount, onUserScrolled = { viewModel.dispatchEvent(ChatViewModel.Event.UserScrolledTranscript) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 6b88c995ab..91b715ce0b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -5,7 +5,6 @@ import com.getcode.ui.core.addIf import kotlin.math.roundToInt import kotlinx.coroutines.launch import kotlinx.coroutines.coroutineScope -import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Column import androidx.compose.animation.core.tween import androidx.compose.runtime.rememberCoroutineScope @@ -120,7 +119,6 @@ internal fun MessageList( webPreviewImageLoader: ImageLoader? = null, webLinkPreviewsEnabled: Boolean = true, onJumpConsumed: () -> Unit = {}, - onLandedMessageLeft: () -> Unit = {}, topBarBottom: Dp = 0.dp, unreadBelowCount: Int = 0, onUserScrolled: () -> Unit = {}, @@ -268,22 +266,6 @@ internal fun MessageList( // Remembered so the row keeps being handed the same reference and stays skippable. val readAttention: () -> Float = remember { { attention.value } } - // The message a jump landed on has been scrolled off screen: the reader has moved on from - // it, and a message link's chip back to where they came from goes with it. Only the - // reader's own drag counts: while pages load, peek answers placeholders for rows on - // screen, and the landed message reads as gone without anything having moved. - LaunchedEffect(attentionId) { - val landed = attentionId ?: return@LaunchedEffect - listState.interactionSource.interactions.first { it is DragInteraction.Start } - snapshotFlow { - listState.layoutInfo.visibleItemsInfo.none { item -> - item.index < messages.itemCount && - (messages.peek(item.index) as? ChatListItem.ContentBubble)?.messageId == landed - } - }.first { it } - onLandedMessageLeft() - } - // Walking the append path, rather than PagingConfig.jumpThreshold: a jump there routes // through PageFetcher::refresh with triggerRemoteRefresh set, so every tap would fire a // RemoteMediator refresh — token = null and a fetch of the newest page — to reach a message