feat(chat): reply to a message - #1424
Open
bmc08gt wants to merge 16 commits into
Open
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Replyon the wire.sendMessagetakes an optionalreplyToMessageId, defaulted so the notificationquick-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.jumpThresholdwould route each jump throughPageFetcher::refreshwith
triggerRemoteRefreshset, firing aRemoteMediatorrefresh to fetch thenewest page in order to reach a message already in the database. The walk needs
an index
chat_messagesdid not have: reaching a message 2,000 rows back in a40,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
ChatQuotePaneland one defaults object, since the iOS reply UI isstill pending design review.