Skip to content

feat(chat): reply to a message - #1424

Open
bmc08gt wants to merge 16 commits into
code/cashfrom
feat/chat-message-reply
Open

feat(chat): reply to a message#1424
bmc08gt wants to merge 16 commits into
code/cashfrom
feat/chat-message-reply

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Reply was resolved as a capability and dispatched as an event that nothing
consumed. This adds the surfaces in between: a Reply row in the selection bar, a
trailing-ward swipe, a composer strip naming what is being cited, a panel inside
the sent bubble, and a MessageContent.Reply on the wire.

sendMessage takes an optional replyToMessageId, defaulted so the notification
quick-reply — which replies to a conversation, not to a message — is unchanged.
The optimistic row carries the same payload as the request, so a quote renders
before the server answers.

Tapping a quote scrolls to the original by walking the Paging append path.
PagingConfig.jumpThreshold would route each jump through PageFetcher::refresh
with triggerRemoteRefresh set, firing a RemoteMediator refresh to fetch the
newest page in order to reach a message already in the database. The walk needs
an index chat_messages did not have: reaching a message 2,000 rows back in a
40,000-row table measured 536 ms unindexed against 20 ms indexed. That index also
serves the existing paged read, which was paying the same scan.

Two behaviours match iOS deliberately: a quoted payment shows its currency flag,
amount and token name rather than a bare number, and the snippet runs to two
lines because one truncates most quoted sentences mid-clause. The styling is
confined to ChatQuotePanel and one defaults object, since the iOS reply UI is
still pending design review.

The transcript reads newest-first within a chat, an order the composite primary
key does not serve, so every page was a fresh scan and sort of the table.
Walking 2,000 rows back in a 40,000-row table measured 536 ms unindexed against
20 ms indexed.

Also makes that order total. timestamp_epoch_ms alone leaves rows sharing a
millisecond to SQLite, and a paged read that re-queries per page can duplicate
or skip one across the seam.

countNewerThan measures how far back a message sits, which bounds the walk to a
quoted message.
sendMessage takes an optional replyToMessageId and wraps the body in
MessageContent.Reply, which the proto mappers already handle in both directions.
The optimistic row carries the same payload as the request, so a quote renders
before the server answers.

The parameter defaults to null: the notification quick-reply replies to a
conversation rather than to a message, and is unchanged.
plainText unwraps Reply so copy and edit act on the body rather than failing on
the citation around it, matching how PendingMutation.replacingText already
applies an edit.

isSelectable widens to any capability. Excluding Reply left a cash bubble
unselectable, since Reply is the only thing a cash message allows.
The quote panel colours a citation by its sender, and iOS's
ComplementaryPalette.swift ports this derivation arithmetic-for-arithmetic.
Pinning the same hexes turns a divergence into a failure rather than 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.
One composable for the composer strip and the in-bubble panel, so the two
cannot drift. A quoted payment shows its flag, amount and token name rather
than a bare number, and the snippet runs to two lines because one truncates
most quoted sentences mid-clause.

Styling is confined to this file and one defaults object: the iOS reply UI is
still pending design review.
Routes MessageContent.Reply through TextBubble rather than giving it a bubble
of its own, so its grouping, edited marker and link handling stay identical to
any other message. The quote's tap is dropped while the selection backdrop is
up, as the cash bubble's target already is.

The panel wraps its content rather than filling the bubble: a short reply to a
long message should not stretch to the full bubble width.
Reply and edit are mutually exclusive, since both own the composer. Unlike an
edit a reply stashes no draft: the draft is the reply, and taking it away at
the moment the user decided to send it is the opposite of what they asked for.

The strip is cleared by the send handler rather than by the reducer.
dispatchEvent reduces before it emits, so clearing it in the reducer would
empty it before the handler could read the target and the reply would go out
as an ordinary message.
Resolved next to the token-metadata lookup, 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 renders no panel rather than an error.

The accent derives from the cited message's own sender id:
ChatParticipant.Contact wraps a device contact and carries no user id, so the
participant cannot colour a counterparty.

The send handler reads the reply target off state and takes the strip down
itself, alongside clearing the draft, because dispatchEvent reduces before it
emits. ChatCoordinator gains getMessage and distanceFromNewest, both local
reads, to serve the citation and the jump.
Order is priority in this bar: 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, so burying it is what would leave that bubble's bar
empty.

Both entry points dispatch ReplyRequested with the bubble rather than a
citation. Building a citation reads the stored message, so the resolve lives in
one handler; a message this device never stored drops the request instead of
opening an empty strip.
A banner rather than the leading-control swap an edit uses. 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 chat that cannot take text never shows a reply strip over
a bar with nothing to send from. The composer claims focus when the strip opens,
for the same reason an edit does: neither entry point raises the keyboard.
Ported from the legacy MessageNode with its numbers intact: a threshold at 40%
of the row's width, a positional threshold of 0.9, an infinite velocity
threshold, and an offset capped at 30%. The row never settles open — the haptic
fires when the threshold is crossed and the action fires as the row springs
back, so an abandoned drag costs nothing.

That refusal comes from confirmValueChange, which the current
AnchoredDraggableState constructor drops, so the port keeps the deprecated one:
without the veto a fling past the threshold settles the row open, a state the
gesture has no way back out of.
Walks the append path rather than using 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.

The walk is local: RemoteMediator.load(APPEND) fires only once the PagingSource
runs dry, and a citation that could not be resolved renders no panel to tap. The
view model resolves distanceFromNewest before handing the target over, so a walk
that cannot reach its message never starts, and one that can is bounded by how
far back the message actually sits. With the index from the first commit,
reaching a message 2,000 rows back measured 20 ms.
The request and the target are separate states on purpose: a message this device
never stored resolves to no distance, so the transcript is never asked to walk
to something it cannot reach.
@bmc08gt bmc08gt self-assigned this Sep 8, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 8, 2026
Reply had no UI coverage, and its two entry points — the selection bar and a
trailing-ward swipe — reach the same composer strip by different paths, so a
break in either was invisible.

The flow types a draft, opens the strip from both entry points, then sends that
same draft as the reply. One string doing both jobs is what distinguishes reply
from edit — an edit stashes the composer, a reply leaves it — and it avoids
`eraseText`, which clears back to wherever the tap put the cursor instead of
emptying the field.

Tag the bubble's quote panel as `bubble_reply_quote`: the citation repeats the
quoted message's own text, so no text matcher can tell it from the bubble it
cites.
A conversation whose newest message is a reply showed no preview at all —
the row rendered the timestamp and nothing else. `formatPreview` mapped
`MessageContent.Reply` to null, so the wrapper swallowed the text inside it.

Unwrap the reply and preview its content as that content would have previewed
on its own. The "You:" prefix comes from the inner content, so it lands once
rather than once per layer. The unwrap is bounded at four levels: nothing the
app sends nests a reply inside a reply, but this content arrives off the wire.

The `-> null` predates the reply feature (#1132); sending a reply is what made
it reachable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant