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 }