Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4968123
perf(chat): index chat_messages for the paged transcript read
bmc08gt Sep 8, 2026
2af89c2
feat(chat): carry a reply target through sendMessage
bmc08gt Sep 8, 2026
ed8f19f
feat(chat): add the ChatQuote model shared by both reply surfaces
bmc08gt Sep 8, 2026
59f16d5
feat(chat): read through a reply wrapper on the transcript bubble
bmc08gt Sep 8, 2026
4abb84f
test(chat): pin the sender palette to the values iOS pins
bmc08gt Sep 8, 2026
a98d7f2
feat(chat): add the quote panel both reply surfaces render
bmc08gt Sep 8, 2026
523c520
feat(chat): render a reply as a text bubble with a citation
bmc08gt Sep 8, 2026
2160d7f
feat(chat): hold the reply target in composer state
bmc08gt Sep 8, 2026
6261190
feat(chat): resolve a reply's citation in the transcript pipeline
bmc08gt Sep 8, 2026
3669d03
feat(chat): offer Reply first in the message selection bar
bmc08gt Sep 8, 2026
fdfb236
feat(chat): show what is being replied to above the composer
bmc08gt Sep 8, 2026
cd34c59
feat(chat): swipe a message to reply to it
bmc08gt Sep 8, 2026
75ce2fa
feat(chat): jump to the message a quote cites
bmc08gt Sep 8, 2026
d5c3dfb
test(chat): cover the jump request's two steps in the reducer
bmc08gt Sep 8, 2026
3368a07
test(chat): cover reply in the Maestro suite
bmc08gt Sep 8, 2026
92b4212
fix(chat): preview a reply as the body its sender typed
bmc08gt Sep 8, 2026
869eab6
fix(messenger): match the iOS reply surface, affordance, and motion
bmc08gt Sep 8, 2026
166a6c7
fix(chat): ground both reply quotes the way iOS does
bmc08gt Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,9 @@
<string name="action_clearMessageSelection">Clear selection</string>
<string name="action_deleteForEveryone">Delete For Everyone</string>
<string name="action_cancelEdit">Cancel edit</string>
<string name="action_reply">Reply</string>
<string name="action_cancelReply">Cancel reply</string>
<string name="label_replyingTo">Replying to %1$s</string>
<string name="action_confirmEdit">Confirm edit</string>
<string name="title_deleteMessage">Delete Message?</string>
<string name="description_deleteMessage">This can\'t be undone</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand 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)
Expand Down Expand Up @@ -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
Expand All @@ -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? ->
Expand All @@ -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<MessageContent.Text>()
.firstOrNull()?.text.orEmpty()
)

else -> ChatQuoteSnippet.Text("")
},
accent = palette?.first,
nameAccent = palette?.second,
)
}

private val maxAmountFlow by lazy {
combine(
transactionController.limits,
Expand Down Expand Up @@ -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<Event.ReplyRequested>()
.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<Event.JumpToMessage>()
.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. */
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -31,6 +34,25 @@ internal object ChatAnimations {
// Matches the scale UIKit's context menu gives its preview on iOS.
val lift: SpringSpec<Float> = 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<Float> =
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f)
private val replySurfaceIntSize: SpringSpec<IntSize> =
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<IntSize> = spring(dampingRatio = 0.88f, stiffness = 250f)
val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -132,6 +146,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
otherReadPointer = otherReadPointer,
onAction = chatActionHandler,
canViewProfile = state.canViewProfile,
onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) },
)
}
}
Loading
Loading