Skip to content

feat(chat): match emoji reactions to iOS - #1576

Merged
bmc08gt merged 58 commits into
code/cashfrom
feat/chat-reactions-parity
Sep 26, 2026
Merged

bmc08gt merged 58 commits into
code/cashfrom
feat/chat-reactions-parity

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Brings Android's chat reactions to the behaviour iOS settled on device in code-payments/code-ios-app#834. The shared model is ported from iOS and runs every vector in reactions.json from code-payments/flipcash-client-orchestrator#17: merge, order, strip, recents and defaults as unit tests, drawability as an instrumented test in :libs:emojis, since Robolectric can't tell which glyphs a device draws. All three test source sets read the one copy at test-vectors/reactions.json.

What's here

  • Model (:shared:chat reactions): ReactionState with confirmed/desired/in-flight state, accept by version, one call per (message, emoji) with taps coalesced. ReactionsDelegate persists confirmed state to reactions_json, so pills survive a restart.
  • Strip on long-press, above the selected bubble: the six defaults, up to six most-used, every other emoji you reacted with on the message (highlighted, even past the cap), then the catalog's first category up to 12, then "+".
  • Pill row under the bubble: two lines at most, overflow collapses into "N more" before "+", which expands the row in place. Selected pills get a thickBorder outline. Long-press on a pill opens the reactors sheet and nothing else.
  • Picker: opens at half height and expands to 92.5%.
  • Reactors sheet: one row per person with all their emoji, newest reaction first.
  • Refresh: GetReactionSummaries for the newest 60 messages when a chat opens or resumes, and for older pages read from Room as they page in, 40 at a time.
  • Recents in DataStore, per account, recorded when a reaction is added.
  • sequence is renamed to version, as in the proto. Rows already stored under sequence still decode.

Departures from #17

The first four follow iOS, which changed them after testing on device. The rest are specific to this PR.

  • Strip contents. fix: replace corrupted youtube asset #17 has six recents, then your other reactions, then "+". Here the six defaults always lead, then up to six most-used, then your other reactions, then catalog fill to 12.
  • Pill row. fix: replace corrupted youtube asset #17 wraps without a limit (three lines at 20 emoji) and gives selected pills a grey outline. Here it caps at two lines with an "N more" pill, and selected pills get a 2dp outline. Pending pills are not dimmed.
  • Reactors sheet. fix: replace corrupted youtube asset #17 opens on the long-pressed emoji with a filter pill per emoji, no All, and each row opens the profile. Here there is no filter: the first page of every emoji is fetched and merged into one row per person, sorted newest reaction first, and rows appear only once each round of pages is in. The pills at the top are a summary and don't respond to taps. Only the avatar and name open the profile, after the sheet dismisses. Placeholder rows show only if nothing has loaded after 300ms. fix: replace corrupted youtube asset #17 argued against merging because the server doesn't order by reacted_ts. Since pages aren't in reacted_ts order, a later page can bring in someone who reacted more recently than people already shown, and their row lands above them.
  • Boosts. fix: replace corrupted youtube asset #17 puts three $ slots behind a reactionBoosts flag. There is no boost UI and no flag. boost stays on the pill model and in the sort.
  • Emoji catalog. fix: replace corrupted youtube asset #17 fixes generateEmojiList to re-download Unicode 17.0 and CLDR 48.2.2. This bundles the synced emoji_catalog.json as a resource instead, as iOS does, so the old generator and its committed inputs are still in the tree.
  • Refresh on open. fix: replace corrupted youtube asset #17 leaves this to the iOS plan. Android follows iOS. Room doesn't record which pages came from the server, so a page counts as server-fetched when the mediator's append was loading as its ids appeared. Under fast scrolling that can refresh a page that didn't need it.
  • Errors show as one-line system toasts with the fix: replace corrupted youtube asset #17 copy, since the chat screen has no in-app toast.
  • Tests. fix: replace corrupted youtube asset #17 asks for screenshot tests of the picker and reactors sheet. The messenger module has no screenshot setup, so there are none.

getReactors now returns the server paging token and hasMore, threaded
through the controller/repo/service/api with a pageSize param, so the
reactors sheet can page past the first 100. Adds
getReactionSummariesByIds, chunked at 100 ids per request via the
MessageIdBatch oneof, for the reaction refresh-on-paging flow.
…ewer version

mergeReactionsJson previously kept a stored emoji as-is when an incoming
payload (a server-fetched message copy) omitted it, leaving a stale pill
once the server side had removed it. Now it tombstones the omitted emoji
(count 0, self/sample reactors cleared, version kept) and only lets an
incoming entry win on a strictly greater version, matching
ReactionState.accept's tie-breaking.
…erlay

After relaunch, a confirmed self-reaction lives only in Room's
reactions_json — ChatStateHolder's in-memory overlay starts empty. A
freshly-created ReactionState picked up no prior confirmed state, so the
first tap on an emoji already reacted to computed an ADD instead of a
REMOVE. snapshot now seeds a new state from ChatMessageDataSource before
folding in the overlay.
observeChatReactions only ever covered messages with a pending tap or a
confirmed overlay from the event stream/refreshReactions; a message whose
reactions live only in a paged Room read had no way to become pills. Document
that contract on the method and give the ViewModel a pure fallback -
MessageReactions.from(summary) runs a ChatMessage's persisted reactions
through ReactionState.applySummary so tombstone filtering and pill order
match the live path exactly.
…igation into ChatViewModel

- Attach per-message reaction pills and canReact to ContentBubble in
  mappedMessages, sourced from the live reaction overlay with Room fallback
  via MessageReactions.from.
- Add ToggleReaction/OpenReactionPicker/OpenReactors events, wired through
  ChatCoordinator.toggleReaction and the screen's navigation to the
  ChatStep.ReactionPicker/Reactors stubs.
- Surface reactionErrors as a snackbar via BottomBarManager.showError.
- Compose the quick strip's entries from RecentReactionsStore + EmojiCatalog
  + EmojiDrawability whenever the selected, reactable message changes; record
  a used emoji on add (not on remove).
- Render ReactionPillRow under each bubble and QuickReactionStrip above a
  selected, reactable bubble in MessageRow/MessageList.
…lifecycle

- Add ChatAction.RefreshReactionIds and ChatViewModel.Event.RefreshReactionIds,
  forwarding batches to ChatCoordinator.refreshReactions.
- In MessageList, refresh the newest 60 loaded ids on open and on every
  screen-lifecycle resume (repeatOnLifecycle(RESUMED), mirroring
  ChatViewModel's own repeatOnLifecycle(STARTED) pattern), and refresh
  Room-sourced pages in ~40-id chunks as they page in, skipping pages the
  RemoteMediator just fetched (loadState.mediator?.append is Loading).
The ViewModel recorded on tap using only the live overlay, and ReactionsDelegate
recorded again on a successful add, so each add counted twice and a take-back of
a reaction stored only in Room counted as a use. Record in the delegate when the
tap is made, against the Room-seeded state, as iOS ConversationReactions.toggle does.
iOS shows "Couldn't add reaction" and "This message has the maximum number of
reactions" as a single-line toast. Drop the bottom-bar error and its invented
second lines.
Mirrors iOS's EmojiPickerModel: Frequently Used row (deduped, order preserved)
then one section per catalog category, or a single search-results section when
the query is non-empty. Undrawable entries are filtered from every section.
REACTORS_PAGE_SIZE matches the reactors sheet's own page size (decision 4)
rather than QueryOptions' general-purpose default of 100.
…ents

ReactorNameResolver is a pure precedence function over self, the cached
UserProfile, the chat's member roster, and a requested sender-profile
fetch, mirroring the sources ChatViewModel.memberParticipant already
reads. ChatStep.ReactionPicker/Reactors now also implement HalfSheet,
matching iOS's .medium/.large detents for both sheets.
One ReactorsListModel per messageId, started on OpenReactors (a pill long-press) rather than the sheet's own composition, so the fetch is already in flight by the time the sheet reads rows/loading for that id.
ChatViewModel now owns a ReactorsPrefetchCache keyed by messageId and starts it in the OpenReactors handler, one step before the navigator pushes the sheet. reactorName()/reactorParticipant() combine the cached-profile store, chat members, and sender profiles per decision 4's precedence, requesting a server fetch when none of them has an answer.
Rework ReactionPickerSheet to mirror iOS's EmojiPickerSheet (node
9768:1402): drop the reused reactors-sheet title, replace the search
row with a black-.26 capsule (magnifier + Search + clear), let the
7-column grid fill the sheet as it expands, and add a floating Haze
glass category bar over the bottom of the grid with a scroll-synced
selection indicator. Show a spinner while the catalog loads and the
existing no-results copy when a search comes back empty.

Departure from iOS: no drag-across-the-bar gesture on the category
bar, tap-only.
Tap/long-press/plus wiring, the previewer's read-only contract,
narrow-width N-more collapse, selection semantics, and the quick
strip's scroll-to-overflow all lacked coverage.

ReactionPillRow's pill tap called onToggle even when canReact was
false, so a previewer's tap silently registered a reaction attempt
instead of doing nothing. Gate it on canReact. Also tag pills/more/plus
chips for lookup and expose selfReacted/highlighted as semantics
selected, since neither existed to assert against.
…"+" to 28dp

iOS hides the row and its "+" while a message has no reactions; Android drew a
"+" under every message. PlusChip also ignored its size, so the chip shrank to
the glyph.
… on iOS

The strip drew inside the bubble's box shifted up 12dp, so it covered the
bubble and ran to the screen edge with "+" scrolled out of view. It now sits in
a popup 16dp above the bubble (below it when the selection bar is in the way),
hugging the sender's side and 16dp inside the window, at iOS's 55dp height and
313dp max width, with "+" pinned outside the scrolling emoji.
The strip stayed on screen over the picker because the message stayed
selected. iOS dismisses its context menu before presenting the picker; this
does the same.
Pills and the "N more" pill never applied their 28dp height, so they shrank
to the text and sat shorter than "+", and their text took Material's dark
content color, leaving the count unreadable on the dark fill. They now match
iOS: 28dp tall, a 15sp emoji and a 13sp medium count in textMain, 4dp apart.
"+" uses the strip's add-reaction icon on a white-18% circle, and both "+"
buttons are labelled "Add reaction" for TalkBack.
@bmc08gt bmc08gt self-assigned this Sep 25, 2026
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates and removed type: feature New functionality labels Sep 25, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 25, 2026
The bar's lift came from onGloballyPositioned, which only sees the
sheet's new position after layout, so the bar trailed the sheet by a
frame and jumped while it was dragged. The sheet host now exposes
LocalSheetOverhang, read from the sheet state's drag offset, and the bar
reads it at placement so both move in the same frame.
@github-actions github-actions Bot added the area: ui Compose UI, theme, components, resources label Sep 26, 2026
The strip popped in at full size in its own popup window, a beat out of step
with the bubble's raise. It now enters as the iOS strip does: the capsule widens
out of the side it hugs with "+" on its growing edge, and the emoji scale in one
after another, settling in about 200ms. Everything animates in the draw phase,
so the popup never resizes.
… lift

Selecting a message hid every row's pill row, so the pills vanished from
the whole transcript while one bubble was raised. They now stay, blurred
and dimmed with their row, and ignore touches like the rest of it.

The strip was built only after the selection landed: a glyph check over
the first catalog category and a DataStore read, then a second state
update. The popup therefore trailed the lift. Those inputs are now kept
ready from init and refreshed after each toggle, and the strip is built
as the message is selected and carried in on the same event.
…nd the backdrop

The lift scaled the whole row, so a selected message's pills rose with its
bubble. iOS lifts only the bubble view; the pills stay in the cell under
the backdrop. Move the scale onto the bubble and give the selected row's
pills the same dim and blur as the rest of the transcript.
…it back

A reaction arriving over the event stream built its message's state from
nothing, then merged it into Room as a full summary. Every stored emoji the
stream hadn't mentioned this session was tombstoned at its stored version,
and GetReactionSummaries at that same version can't replace it. A message
with 16 emoji showed 10 after one live reaction.
…duck the picker's category bar

Pills scale and fade in and out, the row's height springs as pills come and go, and
each chip is clipped before its click handling so the ripple stays in the capsule.
The picker's category bar stops lifting at the top of the grid, so collapsing the
sheet takes the bar down with it instead of lifting it over the search field.
ChatAnimations moves to chat-ui so the pill row can share the reaction springs.
@bmc08gt
bmc08gt merged commit 48050c9 into code/cash Sep 26, 2026
2 of 3 checks passed
bmc08gt added a commit that referenced this pull request Sep 26, 2026
#1576 added recentReactionsStore, emojiCatalogLoader, toastController
and userProfileDataSource to ChatViewModel and updated the tests that
built it, but ChatGroupCashLinkTest came in with #1574 just before and
was missed. On code/cash the messenger unit tests do not compile.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates 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