Skip to content

feat(tipping): tip-chat routing + is_hidden/chat_metadata proto plumbing - #1132

Merged
bmc08gt merged 2 commits into
code/cashfrom
feat/tips-chat-list-shared-mapper
Jul 24, 2026
Merged

bmc08gt merged 2 commits into
code/cashfrom
feat/tips-chat-list-shared-mapper

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

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.protoMetadata.is_hidden (per-viewer, server-computed; e.g. peer on the caller's blocklist)
  • push/v1/model.protoPayload.chat_metadata + new ChatMetadata { sending_user_id, type }

Changes

  • services: map is_hidden into ChatMetadata; map push chat_metadata into NotificationPayload via a new PushChatMetadata domain type (both proto→domain mapping sites updated)
  • persistence: persist is_hidden on chat_metadata (Room v23→24 auto-migration; schema exported)
  • routing: new DeeplinkType.TipChat, Linkify.tipChatById(...), and AppRouter handling (tip/chat/{chatId}); NotificationService routes TIP_DM chat pushes to the tip chat instead of the standard DM
  • tipping: TipPaymentDelegate / TippingCoordinator updates (+ tests)
  • tips list: reuse the send flow's ChatSummary → ConversationReference mapping so tip rows show real message previews and last-activity timestamps (shared mapper extracted into chat-ui)

Notes

  • is_hidden is now plumbed end-to-end (domain → mappers → Room) but not yet acted on in the chat-list VM filter — deferred intentionally.
  • Upstream also added new blocklist/ protos; left out of this PR as nothing references them yet.

Testing

  • :apps:flipcash:app:assembleDebug
  • Unit tests: router, services/flipcash, tipping, payments, persistence db ✅

@github-actions github-actions Bot added type: feature New functionality area: payments Payments, transfers, intents, billing area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: deeplinks Deep link handling, URL routing, and link parsing labels Jul 24, 2026
@bmc08gt
bmc08gt force-pushed the feat/tips-chat-list-shared-mapper branch from e238587 to 1e98b8c Compare July 24, 2026 13:36
bmc08gt added 2 commits July 24, 2026 09:41
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
bmc08gt force-pushed the feat/tips-chat-list-shared-mapper branch 2 times, most recently from e238587 to 7ffffd4 Compare July 24, 2026 13:52
@bmc08gt
bmc08gt merged commit 6b0cf4d into code/cash Jul 24, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the feat/tips-chat-list-shared-mapper branch July 24, 2026 13:53
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deeplinks Deep link handling, URL routing, and link parsing area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: payments Payments, transfers, intents, billing type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant