Skip to content

feat(chat): pin messages in DMs and groups - #1898

Merged
bmc08gt merged 5 commits into
code/cashfrom
feat/chat-pins-ui
Oct 30, 2026
Merged

bmc08gt merged 5 commits into
code/cashfrom
feat/chat-pins-ui

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Chat message pinning on Android, DMs and groups, built against the pinning design note (orchestrator #81) and the chat-pinning frames. Stacked on #1897, which stores can_pin; without it Pin never shows.

State (:apps:flipcash:shared:chat)

  • MessagingOperations gains getPinnedMessages, pinMessage and unpinMessage. A pinned message comes from its stored transcript copy when there is one, else the wire copy is opened the way readMessage opens it, so E2EE pins decrypt on the same path. Pins are not written into the messages table, so the transcript stays contiguous.
  • MessageCapability.Pin is granted from can_pin and never on system messages, tombstones, unconfirmed messages or payment requests. Requests already stay at the top through the strip and pill.

Messenger (:apps:flipcash:features:messenger)

  • ChatPins holds the last GetPinnedMessages read and the summary version it was read at. A newer stored PinSummary.version (advanced by PinsChanged or by the viewer's own pin/unpin) triggers a fresh read. A feed or list write without pin_summary never clears pins.
  • Deleting or editing a pinned message moves no pin version, so the stored copy of each pinned message overlays the read's copy. Deleted ones stay in pinnedIds but are skipped by the bar, its "N of M" count and the list.
  • C2: Pin and Unpin go in the selection bar's overflow, before Report.
  • C1: the pin bar sits under the top bar on its own glass and draws the pin with the reply strip's quote (author rule, name, snippet or thumbnail), shared from chat-ui as ChatQuoteContent. There's no title, pin icon or dismiss. With more than one pin the author rule splits into segments, stacked bottom up from the latest, and the shown pin's segment is lit. The window is 34dp and fits two 16dp segments with a 2dp gap; past two pins the track scrolls to keep the lit one in view (PinSegments, Figma Cycling variant node 13016:87). A tap jumps to the pin it shows and steps to the next older one. A jump resolveJump can't reach shows a toast. TalkBack reads "Pinned message from : ".
  • The bar's list button and the C6 top-bar action use ic_list_bullet_pin, converted path for path from list-bullet-pin.svg (node 13104:84664).
  • C6/C6b: pinSlot puts pins in the top bar's trailing slot while the request strip is out, and back under the top bar once the strip rests as the pill. The strip and pill exist only in groups, so a DM always gets the bar.
  • C7: an upward drag on the strip feeds DeckHostState's existing collapse and settles at halfway. A downward drag still cycles requests (Android's strip cycles vertically, not horizontally as on iOS). The dock holds until a new owed request arrives, which DeckRest.collapsed() already did. The top-bar pin button leaves the composition on the first collapsing frame: the pill takes the trailing slot at full width straight away, and AnimatedVisibility with ExitTransition.None still kept the button laid out for three frames, which truncated the title.
  • L1/L1b/L1c: the list is a Navigation 3 sheet destination, ChatStep.PinnedMessages(chatId, pinAfterUnpin), a HalfSheet on the app's ModalBottomSheetSceneStrategy like Reactors. Its rows come from the flow-shared ChatViewModel's pins, not the route. A row tap returns PinnedMessageJump through navigateForResult and the chat jumps; back, predictive back and a scrim tap return Canceled. It lists pins newest first. Each row draws the transcript's ContentBubble with the sender's avatar and name, then "Pinned by … · date", and Unpin when can_pin. On TOO_MANY_PINS the list opens with a header asking to unpin one, and the waiting message is pinned once an unpin succeeds. The copy doesn't state the cap. The title stays put while rows scroll under it, with a progressive blur at the top and bottom edges. In a row, only the bubble and Unpin take a tap: the bubble claims the press before its own links or card buttons see it and exposes one click action, "Jump to message"; Unpin keeps a 48dp target below the bubble. PinnedMessageRowTest asserts the two actions and drives real taps on the name, meta line and the bubble's link. The header is the reactors sheet's: a centred title with AppBarDefaults.Close at the end, which pops the sheet in every state; its 32dp button gets a 48dp touch area through expandedTouchTarget, which PinnedMessagesSheetTest checks with taps 23dp from its centre.

Tests

  • ChatPinsTest: version handling, a missing summary never clearing pins, tombstone skipping including stored-copy deletes, bar stepping, the C6/C6b/C7 slot rule, and when the list is done (last visible pin gone, or the waiting message pinned).
  • DeckDockTest: dock drag, release past and short of halfway, and the button gone mid-drag.
  • MessageCapabilityTest: the Pin gate, including system, tombstone and payment-request hides.
  • Ten existing ViewModel tests stub the new getPinnedMessages; their relaxed coordinator mocks can't return a Result.

Design vs app tokens

  • Pin sits in the ⋮ overflow next to Report rather than inline after Forward (C2), because Android's selection bar shows Reply and Forward as icons and keeps the rest in overflow.
  • Spacing, type and colours come from CodeTheme tokens (dimens.grid, colors.divider, the chat glass material), not the Figma values, so the bar's height and padding follow the app's grid rather than the frame's exact points.

Frames on the emulator (flipcash_blockstore, screen recordings stepped frame by frame, off-centre taps)

Frame Android Differs from design
C1 pin bar Test group: one pin keeps the single rule. With three, off-centre taps light the bottom segment, then the top one, then the track slides down a segment to light pin 3; the next tap wraps and slides back Height and padding follow dimens.grid, not the frame's points
C2 menu Pin, or Unpin when pinned, in the selection bar Pin sits in the ⋮ overflow next to Report, since Reply and Forward are the only inline icons
C6 strip + button XCTest Bed: strip out, list-and-pin button in the top bar; it opens the list None
C6b pill + bar XCTest Bed after docking: pill in the top bar, pin bar under it None
C7a/C7b swipe to dock XCTest Bed: the button is gone from the first collapsing frame, the title never truncates, and the bar appears when the strip rests as the pill (about 360 ms) Swipe down still cycles requests
L1 list Opens from the bar button and the C6 action with rows drawn in the first frame. A row tap closes the sheet, then the message highlights Predictive back closes on release; the sheet doesn't follow the drag
L1b full 4 pins plus a hidden deleted one hit the cap. The list opened with "Unpin one to pin "Cap test 4"", and unpinning a row pinned Cap test 4 and closed it None
L1c can't pin XCTest Bed: rows without Unpin None
Last unpin The sheet closes, keeping its rows drawn on the way down None
Row hit targets Seeker, Pin test: taps on the avatar, name, meta line, the space beside the bubble and the meta line, and the gap above the meta line change nothing across 54 frames (no ripple). Unpin's edge removes the row; the bubble's corner, and the URL inside it, close the sheet and jump without opening the browser None
Close X Pixel 7a, Pin test: off-centre taps on the X close the sheet twice, sliding it down over two frames; reopening from the C6 action slides it up None

#1884 parsed ViewerState.Permissions.can_pin but never saved it, so every
chat read it back as false. Add chat_metadata.can_pin (DB v51) and re-arm
viewer_state_version on upgrade, as can_edit and can_invite did.
Pinned messages open through the same path as readMessage: the stored copy
when there is one, else the wire copy opened for the chat's view mode.
They are not written into the transcript. MessageCapability.Pin is granted
from can_pin and refused on system messages, tombstones and payment
requests.
@bmc08gt bmc08gt self-assigned this Oct 30, 2026
@github-actions github-actions Bot added the type: feature New functionality label Oct 30, 2026
@bmc08gt
bmc08gt force-pushed the feat/chat-pins-ui branch 3 times, most recently from b08b685 to efee5e7 Compare October 30, 2026 19:11
@github-actions github-actions Bot added the area: ui Compose UI, theme, components, resources label Oct 30, 2026
…trip

The pin bar draws the pinned message with the reply strip's quote, now
shared from chat-ui as ChatQuoteContent, without the strip's 8dp text
padding and with a 10dp rule gap, as iOS's PinBar does. Its edges line up
with the back button and the top bar's pin button, and it slides down from
the top as it fades in. With more than one pin, the author rule splits into
segments stacked bottom up from the latest, the shown pin lit. A 34dp
window holds two segments; past two, the track scrolls to keep the lit one
in view. The bar's list button and the top-bar pin action use the
list-and-pin glyph.

The pinned list is a ChatStep.PinnedMessages sheet destination. It takes
the chat id and, after a refused pin, the message waiting for a slot. Rows
come from the shared ChatViewModel's pins, and a row tap returns
PinnedMessageJump through navigateForResult. The title stays put while rows
scroll under it, with a progressive blur at the top and bottom edges, and
unpinned rows animate out.

In a row, only the bubble and Unpin take a tap. The bubble claims the down
on the Initial pass, so its own links and buttons never fire in the sheet,
and it exposes one click action labelled "Jump to message". Unpin keeps a
48dp target laid out below the bubble. PinnedMessageRowTest asserts those
two actions and drives real taps on the name, meta line and the bubble's
link.

Swiping the request strip up docks it into the pill. The top-bar pin
button leaves the composition on the first collapsing frame, because
AnimatedVisibility kept it laid out beside the pill's slot for three
frames even with ExitTransition.None.

The top-bar pin button sits in the app bar's action slot, so it is as far
from the right edge as back is from the left (45px each on the Seeker),
and it fades and scales in.

The pinned list's header is the reactors sheet's: AppBarWithTitle with a
centred title and AppBarDefaults.Close at the end, which pops the sheet in
every state. The close is drawn at 32dp, so expandedTouchTarget grows its
touch area to 48dp; PinnedMessagesSheetTest taps 23dp out from its centre
in the normal, read-only and full lists.
@bmc08gt
bmc08gt changed the base branch from fix/persistence-can-pin to code/cash October 30, 2026 20:18
@bmc08gt
bmc08gt merged commit 2657812 into code/cash Oct 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant