Repository navigation
feat(chat): match emoji reactions to iOS - #1576
Merged
Merged
Conversation
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.
…n/picker/reactors events
…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.
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.
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.
This was referenced Sep 27, 2026
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.
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.jsonfrom 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 attest-vectors/reactions.json.What's here
:shared:chatreactions):ReactionStatewith confirmed/desired/in-flight state, accept byversion, one call per(message, emoji)with taps coalesced.ReactionsDelegatepersists confirmed state toreactions_json, so pills survive a restart.thickBorderoutline. Long-press on a pill opens the reactors sheet and nothing else.GetReactionSummariesfor 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.sequenceis renamed toversion, as in the proto. Rows already stored undersequencestill decode.Departures from #17
The first four follow iOS, which changed them after testing on device. The rest are specific to this PR.
reacted_ts. Since pages aren't inreacted_tsorder, a later page can bring in someone who reacted more recently than people already shown, and their row lands above them.$slots behind areactionBoostsflag. There is no boost UI and no flag.booststays on the pill model and in the sort.generateEmojiListto re-download Unicode 17.0 and CLDR 48.2.2. This bundles the syncedemoji_catalog.jsonas a resource instead, as iOS does, so the old generator and its committed inputs are still in the tree.