feat(tipping): tip-chat routing + is_hidden/chat_metadata proto plumbing - #1132
Merged
Merged
Conversation
bmc08gt
force-pushed
the
feat/tips-chat-list-shared-mapper
branch
from
July 24, 2026 13:36
e238587 to
1e98b8c
Compare
Reuse the send flow's ChatSummary -> ConversationReference mapping for the Tips chat list instead of leaving preview/lastActivity unpopulated. - Extract ChatSummary.toConversationReference() into chat-ui, moving formatPreview out of ContactListBuilder (text/cash previews) - Add lastActivity to ConversationReference so rows can sort/show recency - ContactListBuilder and TipFlowViewModel now both call the shared mapper - Tip rows render real message previews and last-activity timestamps
Wire the new flipcash proto fields end-to-end and route chat pushes to the tip conversation when the push is for a TIP_DM: - protos: chat Metadata.is_hidden; push Payload.chat_metadata (+ ChatMetadata) - services: map is_hidden into ChatMetadata; map push chat_metadata into NotificationPayload (new PushChatMetadata domain type) - persistence: persist is_hidden (Room v23->24 auto-migration) - routing: DeeplinkType.TipChat + Linkify.tipChatById + AppRouter handling; NotificationService routes TIP_DM pushes to the tip chat - tipping: TipPaymentDelegate / TippingCoordinator updates - messenger UI: ChatParticipant + ParticipantAvatar and related screens
bmc08gt
force-pushed
the
feat/tips-chat-list-shared-mapper
branch
2 times, most recently
from
July 24, 2026 13:52
e238587 to
7ffffd4
Compare
bmc08gt
added a commit
that referenced
this pull request
Sep 8, 2026
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.
bmc08gt
added a commit
that referenced
this pull request
Sep 8, 2026
* perf(chat): index chat_messages for the paged transcript read 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. * feat(chat): carry a reply target through sendMessage 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. * feat(chat): add the ChatQuote model shared by both reply surfaces * feat(chat): read through a reply wrapper on the transcript bubble 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. * test(chat): pin the sender palette to the values iOS pins 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. * feat(chat): add the quote panel both reply surfaces render 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. * feat(chat): render a reply as a text bubble with a citation 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. * feat(chat): hold the reply target in composer state 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. * feat(chat): resolve a reply's citation in the transcript pipeline 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. * feat(chat): offer Reply first in the message selection bar 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. * feat(chat): show what is being replied to above the composer 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. * feat(chat): swipe a message to reply to it 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. * feat(chat): jump to the message a quote cites 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. * test(chat): cover the jump request's two steps in the reducer 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. * test(chat): cover reply in the Maestro suite 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. * fix(chat): preview a reply as the body its sender typed 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. * fix(messenger): match the iOS reply surface, affordance, and motion Reply mode was three ports short of iOS. The composer reused ChatQuotePanel — the card that goes inside a sent bubble — inset by the composer's own horizontal padding, where iOS draws a bare strip whose 4pt rule sits flush to the screen's leading edge. The swipe had no icon, a regression against the legacy MessageNode affordance it replaced. And the strip appeared and vanished on a single frame with no animation at all. ComposerReplyStrip is the composer's own citation now, with ChatQuotePanel left to the bubble. Sharing one composable meant either padding one shape into the other or carrying a mode flag, so they are separate and each keeps its styling in its own defaults. The strip needs two stops of the sender's palette rather than one, because iOS draws the rule in .start and the name in .middle; both come off the one generateComplementaryColorPalette call, so they cannot disagree about whose colour they are. The affordance forced the swipe distances to move with it. Its reveal is defined as offset / triggerThreshold, and Android's threshold was 40% of screen width — about 164dp on a Pixel 10 — against iOS's flat 48pt, so the icon could not read correctly at any point of the drag without porting iOS's absolute 64/48/20/32. The circle is parked at a static offset rather than an animated one: the row it sits in already carries the drag translation, so the two move together. Entry and exit run on ChatMotion.replySurface, spring(0.22, bounce 0). The zero bounce is deliberate — the transcript's bottom inset tracks the bar's height every frame, so an overshoot would drag every message past where it settles. Entry does not fade, matching iOS: the clip edge uncovering the quote is the whole effect. Exit does, so the quote dissolves rather than being sliced off by an edge moving over fully opaque text. The quote is held one target past the state so the collapse animates the citation rather than an empty strip. iOS's bar-surface re-tint while replying is not ported. It swaps a flat fill between backgroundMain and backgroundSecondary; Android's bar is a haze blur over a gradient to transparent, and a flat secondary fill would stop the transcript showing through it. * fix(chat): ground both reply quotes the way iOS does The composer strip was built from a stale copy of iOS's ComposerReplyStrip — a bare strip flush to the screen edge with a hairline close glyph. The shipped design is a rounded card inset to the composer's own margins and grounded in glass, with a filled disc for the dismiss: the glass samples the transcript, so a hairline glyph's contrast changed with whatever message scrolled past. The in-bubble panel had drifted the other way, to a neutral White10 scrim, a fixed 32dp rule, and the author's name in the palette's start stop. iOS tints the ground with the author's colour at 14%, runs the rule the full height, and takes the name from the middle stop, which ChatQuote already carries. - ComposerReplyStrip: 14dp card on the same liquid glass as the nav pill, 6dp rule clipped by the corners, 50dp floor, 22dp disc in a 34dp target. - ChatQuotePanel: tinted ground, full-height 3dp rule, name from nameAccent, and iOS's 6/8/8/1/5dp insets and 0.55/0.75/0.35 text alphas. - chat-ui takes haze as api, since HazeState is now in the strip's signature.
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.
Summary
Builds on the merged size-aware avatar work (#1131) to finish the tips chat-list feature: wires two new flipcash proto fields end-to-end and routes chat push notifications to the tip conversation when the push is for a
TIP_DM.Proto changes (synced from upstream)
chat/v1/model.proto—Metadata.is_hidden(per-viewer, server-computed; e.g. peer on the caller's blocklist)push/v1/model.proto—Payload.chat_metadata+ newChatMetadata { sending_user_id, type }Changes
is_hiddenintoChatMetadata; map pushchat_metadataintoNotificationPayloadvia a newPushChatMetadatadomain type (both proto→domain mapping sites updated)is_hiddenonchat_metadata(Room v23→24 auto-migration; schema exported)DeeplinkType.TipChat,Linkify.tipChatById(...), andAppRouterhandling (tip/chat/{chatId});NotificationServiceroutesTIP_DMchat pushes to the tip chat instead of the standard DMTipPaymentDelegate/TippingCoordinatorupdates (+ tests)ChatSummary → ConversationReferencemapping so tip rows show real message previews and last-activity timestamps (shared mapper extracted intochat-ui)Notes
is_hiddenis now plumbed end-to-end (domain → mappers → Room) but not yet acted on in the chat-list VM filter — deferred intentionally.blocklist/protos; left out of this PR as nothing references them yet.Testing
:apps:flipcash:app:assembleDebug✅