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/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..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) }, + 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 eeb9b3219b..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 @@ -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 @@ -244,6 +245,21 @@ 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, + /** + * 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, @@ -280,7 +296,11 @@ internal class ChatViewModel @AssistedInject constructor( linkCardMemory: LinkCardMemory? = null, featureFlags: FeatureFlagController = NoOpFeatureFlagController, ) : BaseViewModel( - initialState = State(), + initialState = State( + openingAtMessage = openAtMessageId != null, + returnToTitle = returnToTitle, + linkedAsGroup = linkedAsGroup, + ), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { @@ -417,6 +437,24 @@ 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. It stays for the life of the screen: the chip + * goes only by being tapped or by leaving the chat. + */ + 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 @@ -624,6 +662,36 @@ 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 + // 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, 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 + } + /** * 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. @@ -1029,6 +1097,9 @@ 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 } /** @@ -1181,6 +1252,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,17 +3022,39 @@ 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)) - } - .launchIn(viewModelScope) + 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. + // 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) { + 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 // it reads is the one the viewer arrived with. @@ -3445,9 +3563,33 @@ 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?, + linkedAsGroup: Boolean, + ): ChatViewModel } companion object { @@ -3780,7 +3922,10 @@ 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) } is Event.UnreadBoundaryResolved -> { state -> state.copy( unreadBoundary = event.boundary, @@ -3826,3 +3971,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/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..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 @@ -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,22 @@ 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() } }, + hazeState = hazeState, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) + } } }, bottomBar = { 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..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 @@ -272,14 +272,20 @@ internal fun MessageList( // 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 +297,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 +310,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 +906,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 +925,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 +1069,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..8e0c1452b4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReturnToChatChip.kt @@ -0,0 +1,91 @@ +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 +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 +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 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) + .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"), + 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/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..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 @@ -658,4 +658,95 @@ 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 = false)).messageLinkLanding) + assertNull( + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Undetermined, + ).messageLinkLanding + ) + } + + @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( + 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, + ) + } } 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/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 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