diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ca298daae..ba87b4512 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -908,6 +908,9 @@ Clear selection Delete For Everyone Cancel edit + Reply + Cancel reply + Replying to %1$s Confirm edit Delete Message? This can\'t be undone 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 df9e0ee70..034271821 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 @@ -24,6 +24,8 @@ import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.funding.PurchaseMethodController @@ -33,6 +35,7 @@ import com.flipcash.features.messenger.R import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent @@ -57,6 +60,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token +import com.getcode.ui.utils.generateComplementaryColorPalette import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace import com.getcode.view.BaseViewModel @@ -166,6 +170,29 @@ internal class ChatViewModel @Inject constructor( val selection: ChatListItem.ContentBubble? = null, /** The message the composer is editing, or `null` when it is composing a new one. */ val editing: EditingMessage? = null, + /** + * The message the composer is citing, or `null` when it is composing an ordinary message. + * + * Mutually exclusive with [editing]: an edit takes the composer over with the message's own + * body, so citing another message from inside one would send a reply that overwrites a + * third. Unlike [EditingMessage] this stashes no draft — the draft is the reply. + * + * Cleared by the send handler rather than by the reducer: dispatchEvent reduces before it + * emits, so a reducer that cleared it would empty it before the handler could read it. + */ + val replyingTo: ChatQuote? = null, + /** + * A message the transcript has been asked to scroll to, held until the list consumes it. + * + * State rather than a one-shot event, for the reason [messageInputRequested] is: eventFlow + * is replay-0, so a request raised while the list is recomposing would be dropped. + */ + val jumpTarget: Long? = null, + /** + * How far back [jumpTarget] sits from the newest message, which bounds the walk that loads + * it. Set alongside [jumpTarget] and cleared with it. + */ + val jumpBudget: Int? = null, /** * True while the delete confirmation is up. * @@ -263,6 +290,24 @@ internal class ChatViewModel @Inject constructor( data object SubmitEdit : Event data object CancelEdit : Event data object EditingEnded : Event + + /** + * Asks for a reply to [bubble]. Both entry points — the selection bar and the swipe — land + * here rather than on [ReplyToMessage], because turning a bubble into a citation needs the + * stored message, and that read belongs in one place. + */ + data class ReplyRequested(val bubble: ChatListItem.ContentBubble) : Event + + /** Opens the composer's reply strip on an already-resolved citation. */ + data class ReplyToMessage(val quote: ChatQuote) : Event + data object CancelReply : Event + + /** Asks the transcript to scroll to [messageId] — a tap on a quote. */ + data class JumpToMessage(val messageId: Long) : Event + + /** The same request, once the walk's bound is known. */ + data class JumpResolved(val messageId: Long, val budget: Int) : Event + data object JumpConsumed : Event } @OptIn(ExperimentalCoroutinesApi::class) @@ -304,6 +349,16 @@ internal class ChatViewModel @Inject constructor( } else content } else content + // Resolved here, next to the token-metadata lookup, because this is the one + // place in the transcript that already does async per-item work. A citation of + // a message this device never stored resolves to null, and the bubble renders + // its body with no panel rather than an error. + val quote = (content as? MessageContent.Reply)?.let { reply -> + stateFlow.value.chatId + ?.let { chatCoordinator.getMessage(it, reply.repliedMessageId) } + ?.toQuote() + } + val receiptStatus = if (message.isFromSelf) { when (message.deliveryStatus) { DeliveryStatus.SENDING -> ReceiptStatus.SENDING @@ -328,6 +383,7 @@ internal class ChatViewModel @Inject constructor( // taxonomy becomes another input to the resolver rather than a branch at // each action site. capabilities = resolveCapabilities(message, policy), + quote = quote, ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -338,6 +394,47 @@ internal class ChatViewModel @Inject constructor( } } + /** + * The citation shown for [this] message. + * + * The accent comes from the message's own sender id, not from `State.participant`: + * [ChatParticipant.Contact] wraps a device contact and carries no user id, so the participant + * is not a usable source for a counterparty's colour. + */ + private suspend fun ChatMessage.toQuote(): ChatQuote { + val body = content.firstOrNull() + val palette = senderId?.let { generateComplementaryColorPalette(it) } + return ChatQuote( + messageId = messageId, + authorName = if (isFromSelf) { + resources.getString(R.string.title_you) + } else { + stateFlow.value.participant?.name.orEmpty() + }, + snippet = when (body) { + is MessageContent.Cash -> ChatQuoteSnippet.Cash( + amount = body.amount, + tokenName = body.tokenName.ifBlank { + tokenCoordinator.getTokenMetadata(body.mint) + .getOrNull()?.token?.name.orEmpty() + }, + ) + + is MessageContent.Text -> ChatQuoteSnippet.Text(body.text) + + // A reply to a reply cites the inner body, not the nested citation. + is MessageContent.Reply -> ChatQuoteSnippet.Text( + body.content.filterIsInstance() + .firstOrNull()?.text.orEmpty() + ) + + else -> ChatQuoteSnippet.Text("") + }, + accent = palette?.first, + nameAccent = palette?.second, + ) + } + private val maxAmountFlow by lazy { combine( transactionController.limits, @@ -803,6 +900,30 @@ internal class ChatViewModel @Inject constructor( ) } .launchIn(viewModelScope) + + // A bubble is what the UI has; a citation is what the composer needs, and building one + // reads the stored message. A message this device never stored drops the request rather + // than opening a strip with nothing in it. + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val stored = chatCoordinator.getMessage(chatId, event.bubble.messageId) + ?: return@onEach + dispatchEvent(Event.ReplyToMessage(stored.toQuote())) + } + .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. + 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) } /** Leaves edit mode, restoring the draft the edit interrupted. */ @@ -819,11 +940,15 @@ internal class ChatViewModel @Inject constructor( val chatId = stateFlow.value.chatId ?: return@onEach if (textToSend.isBlank()) return@onEach val chatType = stateFlow.value.chatType + // Read here, not in the reducer: the reply strip comes down with the draft, and + // both are the composer emptying itself once the message is on its way. + val replyToMessageId = stateFlow.value.replyingTo?.messageId stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("") + if (replyToMessageId != null) dispatchEvent(Event.CancelReply) viewModelScope.launch { - chatCoordinator.sendMessage(chatId, textToSend) + chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId) .onSuccess { trace("message sent successfully") analytics.messageSentInChat(type = chatType) @@ -1227,6 +1352,7 @@ internal class ChatViewModel @Inject constructor( state.copy( selection = null, confirmingDelete = false, + replyingTo = null, editing = EditingMessage( messageId = event.messageId, originalText = event.text, @@ -1243,6 +1369,28 @@ internal class ChatViewModel @Inject constructor( Event.SubmitEdit -> { state -> state } Event.CancelEdit -> { state -> state } Event.EditingEnded -> { state -> state.copy(editing = null) } + // The strip opens on ReplyToMessage, once the citation resolves; all this does is + // take the selection bar down so the transcript is legible while that read runs. + is Event.ReplyRequested -> { state -> + state.copy(selection = null, confirmingDelete = false) + } + is Event.ReplyToMessage -> { state -> + state.copy( + replyingTo = event.quote, + selection = null, + confirmingDelete = false, + // Reply and edit both own the composer, so opening one closes the other. + editing = null, + ) + } + Event.CancelReply -> { state -> state.copy(replyingTo = null) } + // The request itself changes nothing: the target is only worth holding once the + // walk's bound resolves, and that read is what decides whether it can be reached. + is Event.JumpToMessage -> { state -> state } + is Event.JumpResolved -> { state -> + state.copy(jumpTarget = event.messageId, jumpBudget = event.budget) + } + Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt index 5cb208fbe..be635b43d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt @@ -1,11 +1,14 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.ui.Alignment import androidx.compose.ui.unit.IntSize // All chat animation spring specs in one place. @@ -31,6 +34,25 @@ internal object ChatAnimations { // Matches the scale UIKit's context menu gives its preview on iOS. val lift: SpringSpec = spring(dampingRatio = 0.68f, stiffness = 600f) + // Reply mode entry/exit — the bar grows a strip on top of itself and shrinks back. + // Matches iOS replySurface: .spring(duration: 0.22, bounce: 0). + // + // No bounce, and that is the point: the transcript's bottom inset tracks the bar's height every + // frame, so an overshoot here drags every message past where it settles and back. + val replySurface: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) + private val replySurfaceIntSize: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) + + // Asymmetric, as on iOS: nothing fades in, because the clip edge uncovering the quote is the + // whole effect, and a fade on top of it reads as a second animation. Going away it does fade, + // so the quote dissolves rather than being sliced off by an edge moving over text that is still + // fully opaque. + val replySurfaceEnter: EnterTransition = + expandVertically(replySurfaceIntSize, expandFrom = Alignment.Top) + val replySurfaceExit: ExitTransition = + shrinkVertically(replySurfaceIntSize, shrinkTowards = Alignment.Top) + fadeOut(replySurface) + // Receipt label exit when a new message is sent — fade out + collapse. private val deliveredIntSize: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered) 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 a7393fd6e..3c6aea68c 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 @@ -81,6 +81,20 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit) } + // Both reply entry points land on one event so the citation is resolved in one place; + // turning a bubble into a quote needs a database read. + is ChatAction.ReplyTo -> { + viewModel.dispatchEvent(ChatViewModel.Event.ReplyRequested(action.bubble)) + } + + ChatAction.CancelReply -> { + viewModel.dispatchEvent(ChatViewModel.Event.CancelReply) + } + + is ChatAction.JumpToMessage -> { + viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) + } + is ChatAction.ViewProfile -> { // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs // (see State.canViewProfile), so no gating is needed here. @@ -132,6 +146,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { otherReadPointer = otherReadPointer, onAction = chatActionHandler, canViewProfile = state.canViewProfile, + onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index d9647319d..e8e4fa072 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -48,6 +49,7 @@ import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme +import com.flipcash.shared.chat.ui.ComposerReplyStrip import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit import com.getcode.ui.components.chat.TypingIndicator @@ -124,7 +126,6 @@ internal fun UserControlBottomBar( AnimatedContent( modifier = Modifier .measured { buttonHeight = it.height } - .padding(horizontal = CodeTheme.dimens.inset) .padding(vertical = CodeTheme.dimens.grid.x3) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after @@ -158,80 +159,128 @@ internal fun UserControlBottomBar( ) }, ) { canType -> - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.Bottom, - ) { - // Editing swaps the leading control rather than adding a banner above the bar: - // send-cash is not reachable mid-edit anyway, and cancel is what the slot is - // for while the edit is open. - if (state.editing != null) { - CancelEditButton( - onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, - ) - } else { - SendCashButton( - state = state, - hazeState = hazeState, - hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) - } - } - ) + Column(modifier = Modifier.fillMaxWidth()) { + // A banner, unlike an edit, which swaps the leading control instead. The two differ + // in what the user needs to see: an edit's subject is already in front of them as + // the composer's text, while a reply's subject is a different message that is very + // likely scrolled off screen. Gated on canType so a reply strip never sits above a + // bar with nothing to send from. + // + // The bar grows into the strip rather than the strip appearing over the bar, so + // the reveal is a height animation with the content clipped by the edge that is + // moving. `replyingTo` is held past the dismissal by AnimatedVisibility's own + // retention, so the quote is still there to fade out on the way down. + // + // Held one target past the state: cancelling clears `replyingTo` on the frame + // the collapse starts, and reading it directly would shrink an empty strip. It + // still follows a live change, so replying to a second message while the strip + // is up swaps the quote rather than keeping the first. + var lastQuote by remember { mutableStateOf(state.replyingTo) } + state.replyingTo?.let { lastQuote = it } + AnimatedVisibility( + visible = state.replyingTo != null && canType, + enter = ChatAnimations.replySurfaceEnter, + exit = ChatAnimations.replySurfaceExit, + ) { + lastQuote?.let { quote -> + ComposerReplyStrip( + quote = quote, + onDismiss = { dispatch(ChatViewModel.Event.CancelReply) }, + hazeState = hazeState, + modifier = Modifier + // Inset to the composer row's own margins, so the card's edges + // line up with the field it sits above. + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .testTag("composer_reply_strip"), + ) + } } - - if (canType) { - ChatInput( - modifier = Modifier - .testTag("chat_message_input") - .weight(1f) - .border( - CodeTheme.dimens.border, - CodeTheme.colors.divider, - CodeTheme.shapes.medium, - ) - .hazeBlur(HazeInput.Sources(hazeState), material), - focusRequester = focusRequester, - hint = "Message", - state = state.chatInputState, - // One read of the edit state decides both the glyph and what the tap - // does, so the composer cannot show a checkmark and send a new message. - submit = if (state.editing != null) { - ChatInputSubmit.ConfirmEdit { - dispatch(ChatViewModel.Event.SubmitEdit) - keyboard.restartInput() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.Bottom, + ) { + // Editing swaps the leading control rather than adding a banner above the bar: + // send-cash is not reachable mid-edit anyway, and cancel is what the slot is + // for while the edit is open. + if (state.editing != null) { + CancelEditButton( + onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, + ) + } else { + SendCashButton( + state = state, + hazeState = hazeState, + hazeMaterial = material, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) + } } - } else { - ChatInputSubmit.Send { - dispatch(ChatViewModel.Event.SendMessage) - keyboard.restartInput() + ) + } + + if (canType) { + ChatInput( + modifier = Modifier + .testTag("chat_message_input") + .weight(1f) + .border( + CodeTheme.dimens.border, + CodeTheme.colors.divider, + CodeTheme.shapes.medium, + ) + .hazeBlur(HazeInput.Sources(hazeState), material), + focusRequester = focusRequester, + hint = "Message", + state = state.chatInputState, + // One read of the edit state decides both the glyph and what the tap + // does, so the composer cannot show a checkmark and send a new message. + submit = if (state.editing != null) { + ChatInputSubmit.ConfirmEdit { + dispatch(ChatViewModel.Event.SubmitEdit) + keyboard.restartInput() + } + } else { + ChatInputSubmit.Send { + dispatch(ChatViewModel.Event.SendMessage) + keyboard.restartInput() + } + }, + ) + + // An edit starts from a long-press, which leaves the keyboard down, so the + // composer has to claim focus itself or the pre-filled text sits unreachable. + LaunchedEffect(state.editing?.messageId) { + if (state.editing != null) { + focusRequester.requestFocus() + keyboard.show() } - }, - ) + } - // An edit starts from a long-press, which leaves the keyboard down, so the - // composer has to claim focus itself or the pre-filled text sits unreachable. - LaunchedEffect(state.editing?.messageId) { - if (state.editing != null) { - focusRequester.requestFocus() - keyboard.show() + // A reply starts from a long-press or a swipe, neither of which raises the + // keyboard, so the composer claims focus for the same reason. + LaunchedEffect(state.replyingTo?.messageId) { + if (state.replyingTo != null) { + focusRequester.requestFocus() + keyboard.show() + } } - } - // Restores the pre-#1075 behavior: when OnStartMessageInput raises - // state.messageInputRequested (returning from amount entry after a send, or a - // post-tip open), focus the input and show the keyboard. Co-located with - // ChatInput so focusRequester is guaranteed attached; consumes the request so - // it fires once and a later manual dismiss doesn't re-open it. - LaunchedEffect(state.messageInputRequested) { - if (state.messageInputRequested) { - focusRequester.requestFocus() - keyboard.show() - dispatch(ChatViewModel.Event.OnMessageInputConsumed) + // Restores the pre-#1075 behavior: when OnStartMessageInput raises + // state.messageInputRequested (returning from amount entry after a send, or a + // post-tip open), focus the input and show the keyboard. Co-located with + // ChatInput so focusRequester is guaranteed attached; consumes the request so + // it fires once and a later manual dismiss doesn't re-open it. + LaunchedEffect(state.messageInputRequested) { + if (state.messageInputRequested) { + focusRequester.requestFocus() + keyboard.show() + dispatch(ChatViewModel.Event.OnMessageInputConsumed) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 65df4cc67..fbeed4bd0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Reply import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit @@ -198,10 +199,21 @@ private fun MessageSelectionBar( val capabilities = selection.capabilities val body = selection.plainText - // Order is priority: the first actions keep their icons when the bar runs out of room. Delete - // leads because burying the one action with a confirmation behind a menu makes it a three-tap - // job, and it is the action WhatsApp keeps inline too. + // Order is priority: the first actions keep their icons when the bar runs out of room. Reply + // leads because it is the most common action and the only one a cash bubble offers — burying it + // is the one choice that would leave that bubble's bar empty. Delete follows: putting the one + // action with a confirmation behind a menu makes it a three-tap job. val actions = buildList { + if (MessageCapability.Reply in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_reply), + icon = Icons.AutoMirrored.Outlined.Reply, + testTag = "action_reply_message", + onClick = { dispatch(ChatViewModel.Event.ReplyRequested(selection)) }, + ) + ) + } if (MessageCapability.Delete in capabilities) { add( MessageAction( 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 f4e8aff4e..dd556a7c5 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 @@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withTimeoutOrNull @Composable internal fun MessageList( @@ -57,6 +58,7 @@ internal fun MessageList( otherReadPointer: MessagePointer? = null, onAction: ChatActionHandler, canViewProfile: Boolean, + onJumpConsumed: () -> Unit = {}, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -114,6 +116,44 @@ internal fun MessageList( if (buried > 0) listState.animateScrollBy(buried.toFloat()) } + // 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) + + var index = indexOf(messages, target) + while (index == null && messages.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 at the end would append at the far side of history instead. + val hint = messages.appendHintIndex + if (hint < 0) break + + val before = messages.loadedCount + messages[hint] + + // Wait for that append to land rather than for a frame: one frame is not a + // guarantee, and a walk that races the pager gives up and scrolls nowhere. + // loadedCount grows either way — placeholders on, placeholdersAfter shrinks; off, + // itemCount grows. + val progressed = withTimeoutOrNull(JUMP_STEP_TIMEOUT_MS) { + snapshotFlow { messages.loadedCount to messages.loadState.append } + .first { (count, append) -> count > before || append.endOfPaginationReached } + .first > before + } + if (progressed != true) break + + index = indexOf(messages, target) + } + + index?.let { listState.animateScrollToItem(it) } + onJumpConsumed() + } + // Holds the focused message at the position it was long-pressed at, against the keyboard // shortening the list from below — whether the keyboard came up for the edit or because the // composer was tapped with the selection bar still showing. See FocusPin. @@ -319,3 +359,31 @@ internal fun MessageList( } } // CompositionLocalProvider } + +/** + * How many items are actually loaded, as opposed to standing in as placeholders. + * + * `itemCount` counts placeholders too, so it is the whole chat from the first page onward when + * placeholders are enabled — useless as a measure of what a walk has reached. + */ +private val LazyPagingItems.loadedCount: Int + get() = itemSnapshotList.items.size + +/** + * The first index the pager has nothing for — the hint that drives one more append. + * + * Clamped into range, so with placeholders off (where it is one past the end) it lands on the last + * loaded item instead, which emits the same hint. + */ +private val LazyPagingItems.appendHintIndex: Int + get() = (itemSnapshotList.placeholdersBefore + loadedCount).coerceAtMost(itemCount - 1) + +/** The presented index of [messageId], or `null` while it is still unloaded. */ +private fun indexOf(messages: LazyPagingItems, messageId: Long): Int? = + (0 until messages.itemCount).firstOrNull { i -> + (messages.peek(i) as? ChatListItem.ContentBubble)?.messageId == messageId + } + +private const val JUMP_PAGE_SIZE = 50 +private const val MAX_JUMP_ITEMS = 5_000 +private const val JUMP_STEP_TIMEOUT_MS = 2_000L diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index bb1f0d7d4..bdeb8aac6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -10,8 +10,15 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp @@ -29,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler @@ -119,6 +129,13 @@ internal fun MessageRow( label = "messageLift", ) + val swipe = rememberSwipeToReply( + enabled = bubble != null && + !selecting && + MessageCapability.Reply in bubble.capabilities, + onReply = { bubble?.let { onAction(ChatAction.ReplyTo(it)) } }, + ) + Box( modifier = Modifier .padding(bottom = bottomSpacing) @@ -157,7 +174,10 @@ internal fun MessageRow( // dismiss but the keyboard. onClick = { keyboard.hide() }, ) - }, + } + // No swipe with the backdrop up either, and for the same reason: the bar is already + // acting on a message. + .then(swipe.modifier), ) { when (item) { is ChatListItem.DateSeparator -> Box(insertionModifier) { @@ -215,9 +235,61 @@ internal fun MessageRow( } } } + + SwipeToReplyAffordance( + progress = swipe::progress, + modifier = Modifier.align(Alignment.CenterStart), + ) } } +/** + * The mark the swipe uncovers: a circle in the gutter the row is opening, growing and fading in as + * the drag approaches the distance that fires the reply. + * + * Parked at a fixed offset rather than an animated one. The row it sits in is already translated by + * the drag, so the two move together, and the constant is iOS's per-frame centre + * (`affordanceInset + radius - maxTranslation`) restated as a leading edge, which drops the radius: + * the circle lands 20dp from the row's leading edge at full travel, and off that edge — clipped by + * the list, and transparent besides — at rest. + */ +@Composable +private fun SwipeToReplyAffordance( + progress: () -> Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .offset(x = AFFORDANCE_INSET - SWIPE_MAX_TRANSLATION) + .size(AFFORDANCE_SIZE) + .graphicsLayer { + val fraction = progress() + alpha = fraction + // Never from nothing: the circle is already most of its size when it starts to + // show, so it reads as arriving rather than as inflating. + scaleX = 0.6f + 0.4f * fraction + scaleY = 0.6f + 0.4f * fraction + } + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(AFFORDANCE_SIZE - AFFORDANCE_ICON_INSET * 2), + imageVector = Icons.AutoMirrored.Filled.Reply, + // Decorative: the gesture it marks is already reachable from the selection bar, which + // is what a screen reader drives the reply from. + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + ) + } +} + +private val AFFORDANCE_SIZE = 32.dp +private val AFFORDANCE_INSET = 20.dp +private val AFFORDANCE_ICON_INSET = 8.dp +private val SWIPE_MAX_TRANSLATION = 64.dp + @Composable private fun bottomSpacingFor( index: Int, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt new file mode 100644 index 000000000..bc715cece --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt @@ -0,0 +1,145 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.getcode.util.vibration.LocalVibrator +import kotlin.math.roundToInt + +internal enum class ReplyDragAnchor { Rest, Reply } + +/** + * A trailing-ward drag on a message row that dispatches a reply. + * + * The row never settles open — `confirmValueChange` always refuses — so the gesture is a pull that + * springs back: the haptic fires the moment the threshold is crossed, and the action fires as the + * row returns, which is what makes an abandoned drag cost nothing. + * + * The distances are iOS's, in absolute units rather than the fractions of screen width this was + * ported with. They have to be: [progress] is the fraction of the trigger distance travelled, and it + * is what draws the affordance, so a threshold that moves with the screen would put the icon at a + * different point of its reveal on every device — and on a 411dp screen the old 0.40 fraction put + * the trigger at roughly three times iOS's. + */ +@Composable +internal fun rememberSwipeToReply( + enabled: Boolean, + onReply: () -> Unit, +): SwipeToReplyState { + val density = LocalDensity.current + val vibrator = LocalVibrator.current + val maxPx = with(density) { MAX_TRANSLATION.toPx() } + val triggerPx = with(density) { TRIGGER_THRESHOLD.toPx() } + var crossed by remember { mutableStateOf(false) } + + val anchors = remember(maxPx) { + DraggableAnchors { + ReplyDragAnchor.Rest at 0f + ReplyDragAnchor.Reply at maxPx + } + } + + // The deprecated constructor, deliberately. Its replacement drops confirmValueChange, and that + // veto is the whole gesture: without it a fling past the threshold settles the row open, which + // is a state this interaction has no way back out of. + @Suppress("DEPRECATION") + val dragState = remember(anchors) { + AnchoredDraggableState( + initialValue = ReplyDragAnchor.Rest, + anchors = anchors, + // A fraction of the distance between the anchors, so the trigger lands at + // TRIGGER_THRESHOLD rather than at the full travel. + positionalThreshold = { it * (triggerPx / maxPx) }, + velocityThreshold = { Float.POSITIVE_INFINITY }, + confirmValueChange = { target -> + if (target == ReplyDragAnchor.Reply && !crossed) { + crossed = true + vibrator.tick() + } + false + }, + snapAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + decayAnimationSpec = splineBasedDecay(density), + ) + } + + LaunchedEffect(crossed, dragState.targetValue) { + if (crossed && + dragState.targetValue == ReplyDragAnchor.Rest && + dragState.isAnimationRunning + ) { + onReply() + crossed = false + } + } + + // Nothing to drag and nothing to draw, but the hooks above still have to be called in the same + // order on every composition, so the disabled case is decided here rather than at the top. + if (!enabled) return SwipeToReplyState.Disabled + + return remember(dragState, maxPx, triggerPx) { + SwipeToReplyState( + modifier = Modifier + .anchoredDraggable( + state = dragState, + orientation = Orientation.Horizontal, + ) + .offset { + IntOffset(x = dragState.clampedOffset(maxPx).roundToInt(), y = 0) + }, + // Read through a lambda, not captured: this is sampled inside a graphicsLayer block, so + // the affordance redraws on every frame of the drag without recomposing the row. + offsetPx = { dragState.clampedOffset(maxPx) }, + triggerPx = triggerPx, + ) + } +} + +/** + * What a row needs from the gesture: the modifier that carries it, and how far it has travelled, so + * the row can draw the affordance the drag is uncovering. + */ +internal class SwipeToReplyState( + val modifier: Modifier, + private val offsetPx: () -> Float, + private val triggerPx: Float, +) { + /** How far the drag has come as a fraction of the distance that fires the reply, capped at 1. */ + fun progress(): Float = (offsetPx() / triggerPx).coerceIn(0f, 1f) + + companion object { + val Disabled = SwipeToReplyState(Modifier, { 0f }, 1f) + } +} + +/** + * The drag's travel, capped at the full translation and never NaN — `offset` has no value until the + * anchors have been applied, and a NaN reaching `IntOffset` throws. + */ +@Suppress("DEPRECATION") +private fun AnchoredDraggableState.clampedOffset(maxPx: Float): Float = + offset.takeIf { !it.isNaN() }?.coerceIn(0f, maxPx) ?: 0f + +/** iOS's `maxTranslation`: how far the row itself can move. */ +private val MAX_TRANSLATION = 64.dp + +/** iOS's `triggerThreshold`: the travel that arms the reply and fires the haptic. */ +private val TRIGGER_THRESHOLD = 48.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index bbc557ea3..36cb1294b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -221,4 +223,126 @@ class ChatMessageActionReducerTest { assertNotNull(reduce(editing, ChatViewModel.Event.SubmitEdit).editing) assertNotNull(reduce(editing, ChatViewModel.Event.CancelEdit).editing) } + + private fun quote(messageId: Long = 4) = ChatQuote( + messageId = messageId, + authorName = "Ada", + snippet = ChatQuoteSnippet.Text("the original"), + accent = null, + nameAccent = null, + ) + + @Test + fun `replying opens the strip and clears the selection`() { + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce(selected, ChatViewModel.Event.ReplyToMessage(quote())) + + assertNull(state.selection) + assertEquals(quote(), state.replyingTo) + } + + /** + * Unlike an edit, a reply leaves the composer alone: the draft is the reply. Stashing it the + * way EditingMessage does would take the user's half-written text away at the moment they + * decided to send it. + */ + @Test + fun `replying leaves the draft in the composer`() { + val state = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + assertEquals("half-written", state.chatInputState.text.toString()) + } + + @Test + fun `starting an edit takes the reply strip down`() { + val replying = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + val state = reduce(replying, ChatViewModel.Event.EditMessage(1, "hello")) + + assertNull(state.replyingTo) + assertNotNull(state.editing) + } + + @Test + fun `replying takes an edit down`() { + val editing = reduce( + ChatViewModel.State(), + ChatViewModel.Event.EditMessage(1, "hello"), + ) + + val state = reduce(editing, ChatViewModel.Event.ReplyToMessage(quote())) + + assertNull(state.editing) + assertNotNull(state.replyingTo) + } + + @Test + fun `cancelling the reply keeps the draft`() { + val replying = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + val state = reduce(replying, ChatViewModel.Event.CancelReply) + + assertNull(state.replyingTo) + assertEquals("half-written", state.chatInputState.text.toString()) + } + + /** + * The send handler reads the target off state and clears the strip itself, the same way it + * clears the composer's text. Clearing it here would empty it before the handler ran and send + * the reply as an ordinary message: dispatchEvent reduces before it emits. + */ + @Test + fun `sending leaves the reply in place for the handler to read`() { + val replying = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + val state = reduce(replying, ChatViewModel.Event.SendMessage) + + assertEquals(quote(), state.replyingTo) + } + + /** + * The request only becomes a target once the walk's bound is known — a message this device + * never stored resolves to no distance and so never reaches the transcript. + */ + @Test + fun `a jump request alone sets no target`() { + val state = reduce(ChatViewModel.State(), ChatViewModel.Event.JumpToMessage(7)) + + assertNull(state.jumpTarget) + } + + @Test + fun `a resolved jump carries the target and its bound`() { + val state = reduce(ChatViewModel.State(), ChatViewModel.Event.JumpResolved(7, 240)) + + assertEquals(7L, state.jumpTarget) + assertEquals(240, state.jumpBudget) + } + + @Test + fun `consuming a jump clears both`() { + val jumping = reduce(ChatViewModel.State(), ChatViewModel.Event.JumpResolved(7, 240)) + + val state = reduce(jumping, ChatViewModel.Event.JumpConsumed) + + assertNull(state.jumpTarget) + assertNull(state.jumpBudget) + } } diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index 21f7b8da4..1a126ccb1 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -18,6 +18,8 @@ dependencies { implementation(project(":services:flipcash")) implementation(project(":services:opencode-compose")) implementation(project(":libs:datetime")) + // api: ComposerReplyStrip takes the host's HazeState so its card can sample the transcript. + api(libs.bundles.haze) implementation(libs.androidx.paging.runtime) implementation(libs.compose.paging) api(project(":apps:flipcash:shared:common-ui")) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 3aa23686c..506062d1b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -24,6 +24,15 @@ sealed interface ChatAction { /** Abandons an edit in progress, as a tap on the backdrop behind the edited message does. */ data object CancelEdit : ChatAction + + /** Scrolls the transcript to the message a quote cites. */ + data class JumpToMessage(val messageId: Long) : ChatAction + + /** Opens the composer's reply strip for [bubble]. */ + data class ReplyTo(val bubble: ChatListItem.ContentBubble) : ChatAction + + /** Takes the reply strip back down, leaving the draft where it is. */ + data object CancelReply : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index a5dc8cdb4..8a4eadba7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -36,19 +36,33 @@ sealed interface ChatListItem { * offer, and a later role taxonomy changes the resolver rather than the menu. */ val capabilities: Set = emptySet(), + /** + * The message this one cites, resolved in the transcript pipeline, or `null` when it cites + * nothing — or when it cites a message this device has never stored. The absent case + * renders the body without a panel rather than an error, so a reply to history that was + * never synced still reads as a message. + */ + val quote: ChatQuote? = null, ) : ChatListItem { /** The body a Copy or an Edit acts on, or `null` for a bubble that carries no text. */ val plainText: String? - get() = (content as? MessageContent.Text)?.text + get() = when (val content = content) { + is MessageContent.Text -> content.text + // Copy and edit act on what the user wrote, not on the citation around it. + // PendingMutation.replacingText unwraps the same way when the edit is applied. + is MessageContent.Reply -> + content.content.filterIsInstance().firstOrNull()?.text + else -> null + } /** * Whether long-pressing this bubble should open the selection bar. * - * [MessageCapability.Reply] alone is not enough — replies have no surface yet, so a cash - * bubble would open a bar with nothing in it. + * Any capability is enough. Reply used to be excluded because it had no surface, which left + * a cash bubble — whose only capability is Reply — unselectable. */ val isSelectable: Boolean - get() = capabilities.any { it != MessageCapability.Reply } + get() = capabilities.isNotEmpty() override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt new file mode 100644 index 000000000..66e523b8e --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt @@ -0,0 +1,49 @@ +package com.flipcash.shared.chat.models + +import androidx.compose.ui.graphics.Color +import com.getcode.opencode.model.financial.Fiat + +/** + * A citation of another message, rendered identically by the composer strip and by the panel inside + * a sent bubble. + * + * Resolved once, where the transcript is mapped, rather than by each surface: the original has to be + * looked up in the local database either way, and two lookups would let the strip and the bubble + * disagree about the same message. + */ +data class ChatQuote( + /** The cited message, which is what a tap on the panel scrolls to. */ + val messageId: Long, + val authorName: String, + val snippet: ChatQuoteSnippet, + /** + * The cited sender's colour, used for the rule down the citation's leading edge, or `null` when + * the message carries no sender id. + * + * Nullable because the palette is: `generateComplementaryColorPalette` returns `null` for a + * message with no id, and the fallback for that case is a theme colour only a composable can + * read. Resolving the rest here keeps the SHA-512 derivation to once per quote instead of once + * per frame. + */ + val accent: Color?, + /** + * The next stop of the same sender's palette, used for their name in the composer strip. + * + * Two stops rather than one because iOS draws the rule and the name in different colours, and + * the name sits on the bar's own ground where the rule's darker stop reads as muddy. Both come + * from the one derivation, so they cannot disagree about whose colour this is. + */ + val nameAccent: Color?, +) + +/** What a quote shows of the message it cites. */ +sealed interface ChatQuoteSnippet { + data class Text(val body: String) : ChatQuoteSnippet + + /** + * A quoted payment shows its currency flag, amount and token name rather than a bare number, + * matching what iOS shipped: the amount alone does not identify which payment is being + * discussed. + */ + data class Cash(val amount: Fiat, val tokenName: String) : ChatQuoteSnippet +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt new file mode 100644 index 000000000..032c697f2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -0,0 +1,152 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.getcode.opencode.compose.LocalExchange +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.PriceWithFlag +import com.getcode.ui.core.addIf + +/** + * A citation of another message inside a sent bubble: a rule in the author's colour, their name, and + * a snippet of what they said, on a ground tinted by that same colour. + * + * The composer's citation is [ComposerReplyStrip], not this. They look alike and were one component + * until the composer half was matched to iOS, which grounds it in glass sampling the transcript + * while this one is tinted — a panel inside a filled bubble cannot blur what is behind it, because + * what is behind it is the bubble. Sharing a composable meant either carrying a mode flag or padding + * one into the other's shape, so they are separate and each keeps its styling in its own defaults. + */ +@Composable +fun ChatQuotePanel( + quote: ChatQuote, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val accent = quote.accent ?: CodeTheme.colors.tertiary + val name = quote.nameAccent ?: accent + + Row( + // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a + // fillMaxHeight child in an unbounded Row measures to zero. + modifier = modifier + .clip(QuotePanelDefaults.shape) + // The author's own colour at low alpha rather than a neutral scrim: the panel sits on a + // filled bubble, and tinting it to match the rule is what separates the two surfaces. + .background(accent.copy(alpha = QuotePanelDefaults.groundAlpha)) + .addIf(onClick != null) { Modifier.clickable { onClick?.invoke() } } + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.gap), + verticalAlignment = Alignment.CenterVertically, + ) { + // Square and flush to the panel's leading edge; the panel's own clip is what rounds it. + Box( + modifier = Modifier + .width(QuotePanelDefaults.accentWidth) + .fillMaxHeight() + .background(accent), + ) + + Column( + modifier = Modifier + .padding( + end = QuotePanelDefaults.trailingPadding, + top = QuotePanelDefaults.verticalPadding, + bottom = QuotePanelDefaults.verticalPadding, + ), + verticalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap), + ) { + Text( + text = quote.authorName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when (val snippet = quote.snippet) { + // Two lines, not one: one truncates most quoted sentences mid-clause. + is ChatQuoteSnippet.Text -> Text( + text = snippet.body, + style = CodeTheme.typography.caption, + // White alphas rather than the theme's secondary, which is a blue-grey: the + // ground under this text is the author's colour, and a second hue muddies it. + color = Color.White.copy(alpha = QuotePanelDefaults.snippetAlpha), + maxLines = QuotePanelDefaults.snippetMaxLines, + overflow = TextOverflow.Ellipsis, + ) + + // A quoted payment shows what identifies it, not just a number. + is ChatQuoteSnippet.Cash -> { + val exchange = LocalExchange.current + val currencyCode = snippet.amount.currencyCode.name + Row( + horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.cashGap), + verticalAlignment = Alignment.CenterVertically, + ) { + PriceWithFlag( + amount = snippet.amount.formatted(), + currencyCode = currencyCode, + iconSize = QuotePanelDefaults.flagSize, + flag = exchange.getFlagByCurrency(currencyCode), + text = { formatted -> + Text( + text = formatted, + style = CodeTheme.typography.caption, + color = Color.White.copy( + alpha = QuotePanelDefaults.amountAlpha, + ), + maxLines = 1, + ) + }, + ) + Text( + text = snippet.tokenName, + style = CodeTheme.typography.caption, + color = Color.White.copy(alpha = QuotePanelDefaults.tokenAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** Every measurement the panel makes, carried over from iOS. */ +private object QuotePanelDefaults { + val shape = RoundedCornerShape(8.dp) + val gap = 8.dp + val trailingPadding = 8.dp + val verticalPadding = 6.dp + val nameGap = 1.dp + val cashGap = 5.dp + val accentWidth = 3.dp + val flagSize = 14.dp + const val groundAlpha = 0.14f + const val snippetAlpha = 0.55f + const val amountAlpha = 0.75f + const val tokenAlpha = 0.35f + const val snippetMaxLines = 2 +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index f1b96b66d..89dfd6067 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -47,48 +47,71 @@ private fun ChatSummary.formatPreview( ): String? { val lastMsg = metadata.lastMessage ?: return null val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId - return lastMsg.content.firstOrNull()?.let { content -> - when (content) { - is MessageContent.Text -> { - val message = content.text.takeIf { it.isNotEmpty() } ?: return null - if (sentBySelf) { - resources.getString(R.string.label_chat_preview_sentMessage, message) - } else { - message - } - } - is MessageContent.Cash -> { - val formatted = content.amount.formatted() - // The reserve is branded "Dollars", so naming it reads as "$1.00 of Dollars" — - // the amount alone already says it. Every other token still gets named. - val name = if (content.mint == Mint.usdf) { - "" - } else { - content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } - } - val label = if (name.isNotBlank()) { - resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) - } else { - formatted - } - val previewRes = when (content.action) { - MessageContent.Cash.Action.TIPPED -> - if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash - MessageContent.Cash.Action.SENT -> - if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash - } - resources.getString(previewRes, label) - } + return lastMsg.content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources) +} - // The feed carries the newest message that still has content, so a tombstone only - // reaches here when every message in the chat is deleted — and then there is nothing - // to preview. - is MessageContent.Deleted -> null +/** + * The row's line for one piece of message content, or null when there is nothing worth previewing. + * + * [depth] bounds the reply unwrap below. Nothing the app sends nests a reply inside a reply, but + * this content arrives off the wire, so a malformed chain must not recurse forever. + */ +private fun MessageContent.previewText( + sentBySelf: Boolean, + tokensByMint: Map, + resources: ResourceHelper, + depth: Int = 0, +): String? = when (this) { + is MessageContent.Text -> { + val message = text.takeIf { it.isNotEmpty() } + when { + message == null -> null + sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) + else -> message + } + } - // TODO: - is MessageContent.Media -> null - is MessageContent.Reply -> null - is MessageContent.System -> null + is MessageContent.Cash -> { + val formatted = amount.formatted() + // The reserve is branded "Dollars", so naming it reads as "$1.00 of Dollars" — + // the amount alone already says it. Every other token still gets named. + val name = if (mint == Mint.usdf) { + "" + } else { + tokenName.ifBlank { tokensByMint[mint]?.name.orEmpty() } } + val label = if (name.isNotBlank()) { + resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) + } else { + formatted + } + val previewRes = when (action) { + MessageContent.Cash.Action.TIPPED -> + if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash + MessageContent.Cash.Action.SENT -> + if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash + } + resources.getString(previewRes, label) + } + + // A reply wraps what the sender actually typed, so it previews as that content would have + // without the citation. The row has no room to say what was cited, and the reply's own body is + // the part that changed. The "You:" prefix comes from the inner content, so it lands once + // rather than once per layer. + is MessageContent.Reply -> if (depth >= MAX_REPLY_UNWRAP_DEPTH) { + null + } else { + content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources, depth + 1) } + + // The feed carries the newest message that still has content, so a tombstone only + // reaches here when every message in the chat is deleted — and then there is nothing + // to preview. + is MessageContent.Deleted -> null + + // TODO: + is MessageContent.Media -> null + is MessageContent.System -> null } + +private const val MAX_REPLY_UNWRAP_DEPTH = 4 diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt new file mode 100644 index 000000000..1c1601a06 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -0,0 +1,231 @@ +package com.flipcash.shared.chat.ui + +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +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.graphics.lerp +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.core.R +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.getcode.opencode.compose.LocalExchange +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.PriceWithFlag +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur + +/** + * The quoted original above the composer while a reply is being written: a card carrying a rule in + * the author's own colour, their name over one or two lines of what they said, and the way out on + * the trailing edge. + * + * The card is glass rather than a flat fill, matching iOS, so it reads as a surface floating over + * the transcript rather than as part of the bar. That is also why the dismiss control is a filled + * disc: the ground behind it samples whatever message is scrolled underneath, and a hairline glyph + * on its own changed contrast as the transcript moved. + * + * Deliberately not [ChatQuotePanel], which sits inside a filled bubble and is tinted by the author's + * colour rather than blurred. + */ +@Composable +fun ComposerReplyStrip( + quote: ChatQuote, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val rule = quote.accent ?: CodeTheme.colors.tertiary + val name = quote.nameAccent ?: rule + + val shape = RoundedCornerShape(ComposerReplyStripDefaults.cornerRadius) + // Same liquid glass as the nav pill and the app bar's circular buttons: a wide blur plus a tint + // toward a grey lifted off the (near-black) background, so the card reads as light frosted glass + // above dark content rather than as the background tone. `clip` must precede `hazeBlur` to bound + // the blur to the rounded rect. Falls back to a near-opaque fill of the same tint when the host + // has no HazeState to sample. + val backdrop = CodeTheme.colors.background + val glassBlurRadius = CodeTheme.dimens.grid.x4 + val glassTint = lerp(backdrop, Color.White, 0.18f) + // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. + val liquidGlass = HazeBlurStyle { + blurRadius(glassBlurRadius) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) + } + val ground = if (hazeState != null) { + Modifier.hazeBlur(HazeInput.Sources(hazeState), liquidGlass) + } else { + Modifier.background(glassTint.copy(alpha = 0.9f)) + } + + Row( + // heightIn before the intrinsic pass: `height(IntrinsicSize.Min)` enforces the incoming + // constraints, so the floor survives it. Intrinsic minimum, in turn, is what lets the rule + // fill a height the text column decides — a fillMaxHeight child in an unbounded Row measures + // to zero. + modifier = modifier + .fillMaxWidth() + .clip(shape) + .then(ground) + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) + .heightIn(min = ComposerReplyStripDefaults.minHeight) + .height(IntrinsicSize.Min) + .padding(end = ComposerReplyStripDefaults.trailingPadding), + horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.gap), + verticalAlignment = Alignment.CenterVertically, + ) { + // Square and flush to the card's leading edge; the card's own clip is what rounds it. + Box( + modifier = Modifier + .width(ComposerReplyStripDefaults.ruleWidth) + .fillMaxHeight() + .background(rule), + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = ComposerReplyStripDefaults.textPadding), + verticalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.nameGap), + ) { + Text( + text = quote.authorName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + QuoteLine(quote.snippet) + } + + // A disc rather than a bare ✕, in a hit target wider than the disc. + Box( + modifier = Modifier + .size(ComposerReplyStripDefaults.dismissTarget) + .clickable(onClick = onDismiss) + .testTag("action_cancel_reply"), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(ComposerReplyStripDefaults.dismissDisc) + .background( + CodeTheme.colors.textSecondary.copy(alpha = 0.35f), + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.requiredSize(ComposerReplyStripDefaults.dismissGlyph), + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.action_cancelReply), + tint = CodeTheme.colors.textMain, + ) + } + } + } +} + +/** + * The quoted original itself, one step under the bubble body's size and in the same weight: the + * quote is the subject of the strip, so it is read rather than glanced at. Primary text for the same + * reason — dimming it makes it look like placeholder text for the field below. + */ +@Composable +private fun QuoteLine(snippet: ChatQuoteSnippet) { + val style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium) + when (snippet) { + is ChatQuoteSnippet.Text -> Text( + text = snippet.body, + style = style, + color = CodeTheme.colors.textMain, + maxLines = ComposerReplyStripDefaults.textMaxLines, + overflow = TextOverflow.Ellipsis, + ) + + // A payment carries the flag and the mint's name the cash card leads with. The amount alone + // reads as a number; with the flag beside it, it reads as the payment being answered. + is ChatQuoteSnippet.Cash -> { + val exchange = LocalExchange.current + val currencyCode = snippet.amount.currencyCode.name + Row( + horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.cashGap), + verticalAlignment = Alignment.CenterVertically, + ) { + PriceWithFlag( + amount = snippet.amount.formatted(), + currencyCode = currencyCode, + iconSize = ComposerReplyStripDefaults.flagSize, + flag = exchange.getFlagByCurrency(currencyCode), + text = { formatted -> + Text( + text = formatted, + style = style, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + }, + ) + Text( + text = snippet.tokenName, + style = style, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** Every measurement the strip makes, carried over from iOS. */ +private object ComposerReplyStripDefaults { + val cornerRadius = 14.dp + val ruleWidth = 6.dp + val gap = 9.dp + val nameGap = 2.dp + val cashGap = 6.dp + val textPadding = 8.dp + val trailingPadding = 8.dp + + /** The composer field's own height, so a one-line quote does not sit shorter than it. */ + val minHeight = 50.dp + + /** Sized to the cap height of the amount beside it, so the flag reads as a mark on the line. */ + val flagSize = 16.dp + val dismissTarget = 34.dp + val dismissDisc = 22.dp + val dismissGlyph = 11.dp + const val textMaxLines = 2 +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 8eccb3f86..0baec2b47 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder @@ -55,6 +56,7 @@ import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.SeparatorConfig @@ -139,9 +141,27 @@ fun ContentBubble( }, ) + // A reply is a text bubble with a citation above the body. Routing it through + // the same composable keeps its grouping, edited marker and link handling + // identical to any other message, which is what it is. + is MessageContent.Reply -> TextBubble( + modifier = modifier, + text = content.content.filterIsInstance() + .firstOrNull()?.text.orEmpty(), + isFromSelf = item.isFromSelf, + position = position, + maxWidth = bubbleMaxWidth, + isEdited = item.isEdited, + quote = item.quote, + // Dropped with the backdrop up, as the cash bubble's target is: the tap + // should dismiss the backdrop, not jump the transcript out from under it. + onQuoteClick = item.quote?.takeIf { interactive }?.let { quote -> + { actionHandler(ChatAction.JumpToMessage(quote.messageId)) } + }, + ) + // TODO is MessageContent.Media -> Unit - is MessageContent.Reply -> Unit is MessageContent.System -> Unit } } @@ -150,6 +170,9 @@ fun ContentBubble( private const val EDITED_MARKER_SLOT = "edited-marker" +/** Space between a reply's citation and its body. */ +private val QUOTE_GAP = 6.dp + @Composable private fun TextBubble( text: String, @@ -159,6 +182,8 @@ private fun TextBubble( modifier: Modifier = Modifier, isEdited: Boolean = false, isTombstone: Boolean = false, + quote: ChatQuote? = null, + onQuoteClick: (() -> Unit)? = null, ) { Bubble(isFromSelf, position, maxWidth, modifier) { val linkStyle = SpanStyle( @@ -223,12 +248,28 @@ private fun TextBubble( // No SelectionContainer: long-press is the transcript's selection gesture, and a text // selection handle inside the bubble would consume it before the row ever sees it. Copying // a message is the selection bar's Copy action instead — the same trade WhatsApp makes. - Text( - text = laidOut, - inlineContent = inlineContent, - style = bodyStyle, - color = bodyColor, - ) + // The citation sits inside the bubble, above the body, so the two move together and the + // reply reads as one message rather than as a quote with a message under it. The panel + // wraps its content rather than filling the bubble: a one-word reply to a long message + // should not stretch to the full bubble width. + Column(verticalArrangement = Arrangement.spacedBy(QUOTE_GAP)) { + if (quote != null) { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + // Tagged because the citation repeats the quoted message's own text, so a + // UI test matching on that text cannot tell the two apart. + modifier = Modifier.testTag("bubble_reply_quote"), + ) + } + + Text( + text = laidOut, + inlineContent = inlineContent, + style = bodyStyle, + color = bodyColor, + ) + } if (isEdited) { Text( diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt index 5a7cfbb62..08638df76 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt @@ -42,11 +42,33 @@ class ChatListItemSelectionTest { } @Test - fun `reply alone does not make a bubble selectable`() { - // Cash resolves to Reply only, and replies have no surface yet, so a long-press here would - // open a bar with nothing in it. + fun `a bubble offering only reply is selectable`() { + // Cash resolves to Reply only. Excluding Reply here was right while replies had no + // surface; now it is the one action a cash bubble offers, so excluding it would leave that + // bubble unselectable. val cash = bubble(MessageContent.Text("hello"), setOf(MessageCapability.Reply)) - assertFalse(cash.isSelectable) + assertTrue(cash.isSelectable) + } + + @Test + fun `plainText reads through a reply to the body`() { + // Copy and edit act on what the user wrote, not on the wrapper that cites another message. + val reply = bubble( + MessageContent.Reply(repliedMessageId = 4, content = listOf(MessageContent.Text("sure"))), + setOf(MessageCapability.Copy), + ) + + assertEquals("sure", reply.plainText) + } + + @Test + fun `plainText is null for a reply with no text in it`() { + val reply = bubble( + MessageContent.Reply(repliedMessageId = 4, content = emptyList()), + setOf(MessageCapability.Copy), + ) + + assertNull(reply.plainText) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt new file mode 100644 index 000000000..28d6760a2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -0,0 +1,108 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.core.R +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.ChatSummary +import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The conversation row previews the last message. A reply is a wrapper around the body the sender + * typed, so the row has to look through it — otherwise a chat whose newest message is a reply + * shows no preview at all. + */ +class ChatSummaryPreviewTest { + + private val sentAt = Instant.fromEpochSeconds(1_000) + private val self: ID = listOf(1) + private val other: ID = listOf(2) + + private val resources = mockk() + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + private fun summary( + content: List, + senderId: ID?, + ) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = ChatType.TIP_DM, + members = listOf( + ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList()), + ChatMember(userId = other, userProfile = profile("Them"), pointers = emptyList()), + ), + lastMessage = ChatMessage( + messageId = 42, + senderId = senderId, + content = content, + timestamp = sentAt, + unreadSeq = 0, + ), + lastActivity = sentAt, + ), + unreadCount = 0, + ) + + private fun preview(content: List, senderId: ID? = other): String? = + summary(content, senderId) + .toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + .lastMessagePreview + + @Test + fun `a reply previews as the body the sender typed`() { + val reply = MessageContent.Reply( + repliedMessageId = 7, + content = listOf(MessageContent.Text("on my way")), + ) + + assertEquals("on my way", preview(listOf(reply))) + } + + @Test + fun `a reply the user sent is prefixed once, not once per layer`() { + every { + resources.getString(R.string.label_chat_preview_sentMessage, "on my way") + } returns "You: on my way" + + val reply = MessageContent.Reply( + repliedMessageId = 7, + content = listOf(MessageContent.Text("on my way")), + ) + + assertEquals("You: on my way", preview(listOf(reply), senderId = self)) + } + + @Test + fun `a reply carrying no body has nothing to preview`() { + val reply = MessageContent.Reply(repliedMessageId = 7, content = emptyList()) + + assertNull(preview(listOf(reply))) + } + + @Test + fun `a reply nested past the unwrap bound gives up rather than looping`() { + // Nothing the app sends looks like this; it stands in for malformed content off the wire. + var nested: MessageContent = MessageContent.Text("buried") + repeat(6) { nested = MessageContent.Reply(repliedMessageId = 7, content = listOf(nested)) } + + assertNull(preview(listOf(nested))) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt new file mode 100644 index 000000000..6aa837341 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt @@ -0,0 +1,47 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.getcode.ui.utils.generateComplementaryColorPalette +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * The quote panel colours a citation by its sender, and iOS's ComplementaryPalette.swift is a + * documented arithmetic-for-arithmetic port of this function. These are the hexes iOS's own tests + * pin, so a change to either side's derivation surfaces here rather than as two apps quietly + * colouring the same person differently. + * + * Compared as 8-bit hex, which is what iOS pins. The two platforms carry HSV out to different float + * types, so equal colours are equal once quantized to a channel byte and not before. + * + * Only the first two stops are pinned. iOS deliberately omits the third stop's WCAG correction, + * and a quote uses neither. + */ +class ComplementaryPaletteParityTest { + + @Test + fun `the palette matches the values iOS pins`() { + val first = assertNotNull(generateComplementaryColorPalette(IOS_IDENTIFIER_ONE)) + assertEquals("#D69336", first.first.hex()) + assertEquals("#D9CC3E", first.second.hex()) + + val second = assertNotNull(generateComplementaryColorPalette(IOS_IDENTIFIER_TWO)) + assertEquals("#D936CB", second.first.hex()) + assertEquals("#D93E98", second.second.hex()) + } + + private fun Color.hex(): String = "#%06X".format(toArgb() and 0xFFFFFF) + + private companion object { + // Byte-for-byte the identifiers iOS's own palette test uses: the UUIDs + // 8B3D4E1A-0000-4000-8000-000000000007 and the all-zero UUID, in the order + // `withUnsafeBytes(of: id.uuid)` hands them to SHA-512. + val IOS_IDENTIFIER_ONE: List = listOf( + 0x8B.toByte(), 0x3D, 0x4E, 0x1A, 0x00, 0x00, 0x40, 0x00, + 0x80.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, + ) + val IOS_IDENTIFIER_TWO: List = List(16) { 0.toByte() } + } +} 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 0088d4bf0..c1828277d 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 @@ -137,6 +137,20 @@ interface MessagingOperations { /** Observes messages in [chatId] via Paging 3, with remote-mediated page loads. */ fun observeMessagesPaged(chatId: ChatId): Flow> + /** + * The stored message [messageId] in [chatId], or `null` if this device has never stored it. + * + * A local read only. A reply citing a message from before this device's history resolves to + * `null`, which the transcript renders as a reply with no citation rather than as an error. + */ + suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? + + /** + * How far back [messageId] sits from the newest message in [chatId], or `null` when this + * device has not stored it. Bounds the walk that scrolls a quote's citation into view. + */ + suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? + /** Observes the member list for [chatId]. */ fun observeMembers(chatId: ChatId): Flow> @@ -146,8 +160,18 @@ interface MessagingOperations { /** Fetches the full message history for [chatId] from the server and persists locally. */ suspend fun loadMessages(chatId: ChatId) - /** Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. */ - suspend fun sendMessage(chatId: ChatId, content: String): Result + /** + * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. + * + * [replyToMessageId] cites a message in the same chat, which wraps the body in + * [MessageContent.Reply]. It defaults to `null` so a caller with no message to cite — the + * notification quick-reply replies to a conversation, not to a message — is unchanged. + */ + suspend fun sendMessage( + chatId: ChatId, + content: String, + replyToMessageId: Long? = null, + ): Result /** Retries a failed pending message: resets to SENDING and re-sends to the server. */ suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result 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 d82b1d194..ce2a871c2 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 @@ -152,6 +152,12 @@ class MessagingDelegate @Inject constructor( } } + override suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? = + messageDataSource.getMessage(chatId, messageId) + + override suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? = + messageDataSource.distanceFromNewest(chatId, messageId) + override fun observeMembers(chatId: ChatId): Flow> { return memberDataSource.observeMembers(chatId) } @@ -187,7 +193,11 @@ class MessagingDelegate @Inject constructor( } } - override suspend fun sendMessage(chatId: ChatId, content: String): Result { + override suspend fun sendMessage( + chatId: ChatId, + content: String, + replyToMessageId: Long?, + ): Result { if (content.isBlank()) { return Result.failure(IllegalArgumentException("Cannot send a blank message")) } @@ -195,14 +205,19 @@ class MessagingDelegate @Inject constructor( val senderId = userManager.accountId ?: return Result.failure(IllegalStateException("Cannot send message without an account")) - val content = listOf(MessageContent.Text(content)) + val body = listOf(MessageContent.Text(content)) + // The optimistic row and the request carry the same payload, so the quote renders before + // the server answers rather than appearing when it does. + val payload = replyToMessageId + ?.let { listOf(MessageContent.Reply(repliedMessageId = it, content = body)) } + ?: body val (_, clientMessageId) = messageDataSource.insertPending( chatId = chatId, - content = content, + content = payload, senderId = senderId, ) - return messagingController.sendMessage(chatId, content, clientMessageId) + return messagingController.sendMessage(chatId, payload, clientMessageId) .onSuccess { serverMessage -> messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) advanceReadPointer(chatId, serverMessage.messageId) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt index 3d645e813..0c3a6a775 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt @@ -1,10 +1,12 @@ package com.flipcash.shared.chat import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.PendingMessage import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.delegates.MessagingDelegate @@ -12,6 +14,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -170,4 +173,64 @@ class MessagingMutationTest { coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } assertTrue(delegate.observePendingMutations(chatId).first().isEmpty()) } + + /** + * A sender that cites a message wraps the body rather than sending alongside it: the proto + * carries one content list, and the reply *is* the message. + */ + @Test + fun `a reply wraps the text in Reply content carrying the target id`() = runTest { + // Relaxed: sendMessage's success path advances the read pointer through this same + // controller, which is not what these tests are asserting on. + val controller = mockk(relaxed = true) + val dataSource = mockk(relaxed = true) + val sentContent = slot>() + coEvery { dataSource.insertPending(any(), any(), any()) } returns + PendingMessage(message = stored, clientMessageId = ClientMessageId(byteArrayOf(9))) + coEvery { controller.sendMessage(any(), capture(sentContent), any()) } returns + Result.success(stored) + + delegateWith(controller, dataSource).sendMessage(chatId, "sure", replyToMessageId = 7) + + val reply = assertIs(sentContent.captured.single()) + assertEquals(7L, reply.repliedMessageId) + assertEquals(listOf(MessageContent.Text("sure")), reply.content) + } + + @Test + fun `an ordinary message is still bare text`() = runTest { + // Relaxed: sendMessage's success path advances the read pointer through this same + // controller, which is not what these tests are asserting on. + val controller = mockk(relaxed = true) + val dataSource = mockk(relaxed = true) + val sentContent = slot>() + coEvery { dataSource.insertPending(any(), any(), any()) } returns + PendingMessage(message = stored, clientMessageId = ClientMessageId(byteArrayOf(9))) + coEvery { controller.sendMessage(any(), capture(sentContent), any()) } returns + Result.success(stored) + + delegateWith(controller, dataSource).sendMessage(chatId, "hello") + + assertEquals(listOf(MessageContent.Text("hello")), sentContent.captured) + } + + /** + * The optimistic row carries the same payload as the request, so the quote is on screen before + * the server answers rather than appearing when it does. + */ + @Test + fun `the optimistic row carries the reply too`() = runTest { + // Relaxed: sendMessage's success path advances the read pointer through this same + // controller, which is not what these tests are asserting on. + val controller = mockk(relaxed = true) + val dataSource = mockk(relaxed = true) + val pendingContent = slot>() + coEvery { dataSource.insertPending(any(), capture(pendingContent), any()) } returns + PendingMessage(message = stored, clientMessageId = ClientMessageId(byteArrayOf(9))) + coEvery { controller.sendMessage(any(), any(), any()) } returns Result.success(stored) + + delegateWith(controller, dataSource).sendMessage(chatId, "sure", replyToMessageId = 7) + + assertIs(pendingContent.captured.single()) + } } diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json new file mode 100644 index 000000000..db1708730 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json @@ -0,0 +1,787 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "2e3e1849ac3f28ae6737cdcca9247ae6", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2e3e1849ac3f28ae6737cdcca9247ae6')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 336a30435..0098fac43 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -93,8 +93,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 29, to = 30), // chat_metadata.analytics_counted_through AutoMigration(from = 30, to = 31), // user_profiles.username (nullable) AutoMigration(from = 31, to = 32, spec = FlipcashDatabase.Migration31To32::class), + AutoMigration(from = 32, to = 33), // chat_messages index on (chat_id_hex, timestamp_epoch_ms) ], - version = 32, + version = 33, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { 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 0a36d103b..a134687a6 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 @@ -13,10 +13,15 @@ import kotlinx.coroutines.flow.Flow @Dao interface ChatMessageDao { - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC, message_id ASC") fun observeMessages(chatIdHex: String): Flow> - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC") + /** + * `message_id` breaks ties because `timestamp_epoch_ms` alone is not a total order: messages + * sent in a burst, or stamped from one server clock read, share a millisecond. A paged read + * re-queries per page, so an unstable order there duplicates or skips a row across the seam. + */ + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC, message_id DESC") fun observeMessagesPaged(chatIdHex: String): PagingSource /** @@ -61,6 +66,13 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId LIMIT 1") suspend fun getMessage(chatIdHex: String, messageId: Long): ChatMessageEntity? + /** + * How many messages in [chatIdHex] are newer than [timestampEpochMs] — the distance back from + * the newest message, which is what bounds the walk to a quoted message. + */ + @Query("SELECT COUNT(*) FROM chat_messages WHERE chat_id_hex = :chatIdHex AND timestamp_epoch_ms > :timestampEpochMs") + suspend fun countNewerThan(chatIdHex: String, timestampEpochMs: Long): Int + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ChatMessageEntity) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index 370697d99..df9c27e0a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -2,6 +2,7 @@ package com.flipcash.app.persistence.entities import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import com.flipcash.app.persistence.converters.MessageContentSerialized enum class MessageStatus { @@ -10,9 +11,21 @@ enum class MessageStatus { FAILED, } +/** + * The transcript reads this table one page at a time, ordered newest-first within a chat, and the + * composite primary key `(chat_id_hex, message_id)` does not serve that order. Without the index + * every page is a fresh scan and sort of the whole table: walking 2,000 rows back in a 40,000-row + * table measured 536 ms unindexed against 20 ms indexed. + */ @Entity( tableName = "chat_messages", primaryKeys = ["chat_id_hex", "message_id"], + indices = [ + Index( + value = ["chat_id_hex", "timestamp_epoch_ms"], + name = "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + ), + ], ) data class ChatMessageEntity( @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 601b4ca95..1ce9cccfe 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.persistence.dao import android.content.Context +import androidx.paging.PagingSource import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.flipcash.app.persistence.FlipcashDatabase @@ -137,6 +138,51 @@ class ChatMessageDaoTest { assertEquals(3L, stored.unreadSeq) } + /** + * Two messages can share a millisecond — a burst send, or a server batch stamped from one clock + * read. `timestamp_epoch_ms DESC` alone leaves their order to SQLite, so a paged read that + * re-queries per page can hand back the same row twice or skip one. The tie-breaker makes the + * order total. + */ + @Test + fun `messages sharing a timestamp order by message id, newest first`() = runTest { + val sameMs = 5_000L + dao.upsert( + listOf( + text(1, "first").copy(timestampEpochMs = sameMs), + text(2, "second").copy(timestampEpochMs = sameMs), + text(3, "third").copy(timestampEpochMs = sameMs), + ) + ) + + val page = dao.observeMessagesPaged(CHAT_HEX).load( + PagingSource.LoadParams.Refresh(null, 10, false) + ) as PagingSource.LoadResult.Page + + assertEquals(listOf(3L, 2L, 1L), page.data.map { it.messageId }) + } + + /** + * How far back a message sits from the newest, which is what bounds the jump walk. Counts + * strictly newer rows, so the newest message is at distance 0. + */ + @Test + fun `countNewerThan measures the distance back to a message`() = runTest { + dao.upsert((1L..5L).map { text(it, "m$it") }) + + assertEquals(0, dao.countNewerThan(CHAT_HEX, 5 * 1_000)) + assertEquals(2, dao.countNewerThan(CHAT_HEX, 3 * 1_000)) + assertEquals(4, dao.countNewerThan(CHAT_HEX, 1 * 1_000)) + } + + @Test + fun `countNewerThan ignores other chats`() = runTest { + dao.upsert((1L..5L).map { text(it, "m$it") }) + dao.upsert(text(9, "elsewhere").copy(chatIdHex = OTHER_HEX)) + + assertEquals(2, dao.countNewerThan(CHAT_HEX, 3 * 1_000)) + } + private companion object { const val CHAT_HEX = "aabb" const val OTHER_HEX = "ccdd" 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 f36569854..4c54d20ad 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 @@ -132,6 +132,14 @@ class ChatMessageDataSource @Inject constructor( suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? = db?.chatMessageDao()?.getMessage(mapper.chatIdHex(chatId), messageId)?.let { toChatMessage(it) } + /** 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 + val hex = mapper.chatIdHex(chatId) + val stored = dao.getMessage(hex, messageId) ?: return null + return dao.countNewerThan(hex, stored.timestampEpochMs) + } + suspend fun getInboundMessagesInRange( chatId: ChatId, selfId: ID, diff --git a/maestro/README.md b/maestro/README.md index 36313d1da..8ebe32d1c 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -143,6 +143,9 @@ maestro/run.sh maestro/tipping_setup.yaml the unsent draft the edit displaced comes back on cancel and on confirm alike - `chat_message_delete.yaml` — delete for everyone: the confirmation sheet, Back leaving the message alone, and confirming taking it out of the transcript +- `chat_message_reply.yaml` — reply from the selection bar and from a trailing-ward swipe: the + strip leaves the draft alone where an edit stashes it, and the sent bubble carries a citation + that is tappable - `blocking.yaml` — block a chat participant from their profile, verify in My Account → Blocked, then unblock (leaves the account clean) - `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow diff --git a/maestro/chat_message_reply.yaml b/maestro/chat_message_reply.yaml new file mode 100644 index 000000000..ee0d20e8c --- /dev/null +++ b/maestro/chat_message_reply.yaml @@ -0,0 +1,82 @@ +appId: com.flipcash.app.android +name: "Chat — reply to a message" +tags: + - smoke + - chat +--- +# Reply has two ways in — the selection bar and a trailing-ward swipe — and both open the same +# strip above the composer. Where an edit takes the composer over and stashes what was in it, a +# reply cites a different message and so leaves the draft alone; that is the discriminating +# assertion here. +# +# Fund-safe. Everything it cites and sends is its own, like chat_message_selection.yaml. +- runFlow: + file: subflows/login_with_flags.yaml + env: + BETA_FLAGS: "" +- runFlow: subflows/open_tip_chat.yaml + +- evalScript: ${output.body = 'e2e reply-to ' + Date.now()} +- evalScript: ${output.draft = 'e2e draft ' + Date.now()} + +- tapOn: { id: chat_message_input } +- inputText: "${output.body}" +- tapOn: { id: chat_send_icon } +- extendedWaitUntil: { visible: "${output.body}", timeout: 10000 } + +# An unsent draft for the strip to sit above rather than displace. +- tapOn: { id: chat_message_input } +- inputText: "${output.draft}" +- assertVisible: "${output.draft}" + +# Entry point one: the selection bar. Reply leads the bar, so it is inline unless the screen is +# narrow enough to push it under the overflow — the subflow handles either. +- runFlow: + file: subflows/select_own_message.yaml + env: + MESSAGE_BODY: "${output.body}" +- runFlow: + file: subflows/tap_message_action.yaml + env: + ACTION_ID: action_reply_message + ACTION_LABEL: "Reply" +- assertVisible: { id: composer_reply_strip } +- assertVisible: { id: action_cancel_reply } +# The composer is still a composer: the draft is untouched and send is still send, neither of +# which is true during an edit. +- assertVisible: "${output.draft}" +- assertVisible: { id: chat_send_icon } + +# Taking the strip down leaves the draft where it was. +- tapOn: { id: action_cancel_reply } +- assertNotVisible: { id: composer_reply_strip } +- assertVisible: "${output.draft}" + +# Entry point two: the swipe. The row never settles open — it springs back and dispatches on the +# way home — so there is nothing to undo afterwards, and the strip is the only evidence it fired. +- swipe: + from: + text: "${output.body}" + direction: RIGHT + duration: 400 +- extendedWaitUntil: { visible: { id: composer_reply_strip }, timeout: 5000 } + +# Sending with the strip up carries the citation. The draft is what goes out — it has sat through +# both entry points untouched, so sending it is the same assertion carried one step further. It is +# also the only way to be sure of what is in the composer: a tap puts the cursor where it lands, +# so `eraseText` clears back to that point and leaves the rest. +- tapOn: { id: chat_send_icon } + +# The citation renders on the optimistic row, before the server has answered. Asserted by id +# rather than by the quoted text, which is the original message's own body and so matches the +# bubble above as well. +- extendedWaitUntil: { visible: "${output.draft}", timeout: 10000 } +- assertVisible: { id: bubble_reply_quote } +- assertNotVisible: { id: composer_reply_strip } + +# The citation is live. This transcript is short enough that the cited message is already on +# screen, so what this checks is that the tap resolves and the walk leaves the chat intact — +# not the scroll distance. +- tapOn: { id: bubble_reply_quote } +- assertVisible: "${output.body}" +- assertVisible: { id: chat_screen }