Skip to content

feat(messenger): jump-to-bottom button with unread badge - #1783

Merged
bmc08gt merged 23 commits into
code/cashfrom
feat/chat-jump-to-bottom
Oct 15, 2026
Merged

bmc08gt merged 23 commits into
code/cashfrom
feat/chat-jump-to-bottom

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Adds a floating jump-to-bottom button to the chat transcript. It carries a blue badge counting the inbound messages below what you've seen, and the count goes down as you scroll toward the bottom.

How the badge counts

  • On open, it matches the unread divider. Before the first drag it shows the divider's count, or 0 if there's no divider, plus anything that arrives after the chat opened. Programmatic scrolls don't release that hold.
  • After the first drag, it counts what's below the viewport. It counts inbound messages above the highest inbound id you've seen. The count comes from the database, not the rendered rows, because the list is paged. It caps at 99+.
  • New arrivals start after the first successful newest-page load. The cutoff is set to whichever is higher, the read pointer or the newest inbound id. Until then nothing counts as an arrival, so history stored by another load path never inflates the badge. If the newest page never loads, arrivals undercount rather than overcount.
  • Own send. A successful send (text, media or retry) advances the read pointer and drops the badge to 0. The pointer advance logs messageReceived/tipReceived for each inbound message it crosses. That's existing reportCrossedMessages behaviour, and iOS does the same.
  • System messages are excluded. They're stored with no sender, and every badge and divider query requires a sender. ChatMessageDaoTest has a case for this.

The button

  • When it shows. It appears once the list is positioned and away from the bottom. It hides while a message is selected or raised, and while hidden it takes no taps.
  • What a tap does. It jumps if the bottom is more than 100 rows away, otherwise it animates there.
  • Where it sits. It's centred over the composer's $ button. The $ row and the jump button both place themselves from one inset read in the layout phase (ComposerInsets.sidePx), so they move together while the keyboard opens and closes. A 30fps recording on an emulator showed a 0px gap between their centres on every frame. Before this change the jump button trailed by up to 9px.
  • How it looks. It uses the composer's glass: the same haze material, fill and rim as the message bar and the $ button, with no shadow. The message bar, the $ button, the join-requests card and this button now take their haze from one chatGlassMaterial() in ChatGlass.kt, so the four can't drift apart.

Design: Figma node 11721:806 (Option A). The decision notes are in node 11722:207.

agent-team and others added 22 commits October 14, 2026 17:32
Floating glass button shown while the transcript is off the newest row. Its
badge counts inbound messages past the viewer's stored READ pointer, the
pointer the read reporter advances, using the unread divider's query as a
Flow so the two start equal and the badge falls as rows are read.

Adds a badgeFocus theme color (#0A84FF).
The count stream read the account id once and fell back to an empty flow when the
per-user database was missing, so a screen that subscribed before login finished
stayed at 0 for the session. Both inputs are now resolved reactively, as
hasEverTipped does, and resubscribe when either changes.
The visibility derivation was remembered without a key, but selecting is a plain
Boolean, so it kept the value from its first read and the button stayed up behind
the selection backdrop.
… until the viewer scrolls

The badge opens at the unread divider's own count instead of subtracting rows already
read on screen, and rises by one for each inbound message that arrives after the open.
It follows the live count after the first drag or fling on the settled transcript, or
once the viewer's own message is confirmed. Scrolls the list makes itself do not
release it.
…pshot

newestInboundId and resolveUnreadBoundary returned null/None while the account id or the
per-user database was missing. The view model mapped null to -1, so once both came up every
inbound row in the chat counted as an arrival. Both now suspend until ready, so -1 only
means no inbound at open.
… the newest inbound

The frozen badge counted as an arrival every inbound message past the
newest inbound id stored at open. With an empty cache that id is absent,
so history fetched after the open counted as arrivals and inflated the
badge.

The cutoff is now the later of the viewer's read pointer and the newest
inbound id, both read after the account and database are ready. History
at or below the pointer is already read, so it can load late without
counting.

The test that asserted every later inbound counts with no inbound at open
encoded the bug. It now covers a read pointer with an empty cache, and the
no-pointer, no-inbound case keeps counting everything.
The cutoff was read before the opening sync, so with an empty cache the
history that sync stored counted as arrivals. It is now read after
openTranscript returns, and nothing counts as an arrival until then. A
message that arrives during the load is not counted.

The frozen badge also read its divider term from unreadBoundary, which
the UntilSend lifetime clears when a message is sent, before the send
succeeds. The opening count is now snapshotted into state at open, so a
failed send leaves the badge where it was.
… recording the badge cutoff

The cutoff was read after openTranscript returned, which also happens when the
load fails or is skipped (group feed not synced, outside readers load elsewhere),
so history stored later counted as arrivals. ChatCoordinator.newestPageLoads()
now emits when a newest-page load is stored, from any path; the view model
records the cutoff on the first emission for its chat.
…load

Drives the real ChatViewModel open path: no cutoff on open, none after a
load for another chat, recorded once this chat's newest page lands (history
stored by then is behind it), and not moved by later loads.
The painted fade covered the newest message rows, and the button's glass blur already separates it.
The jump button used the plain inset as its end padding while the $ button sits at the bar's side inset and is wider, so their centres differed. Its end padding is now the composer's side inset plus half the width difference.
…er queries unchanged

Adds a ChatMessageDaoTest case that inserts a senderless System row and checks the inbound count, newest inbound id, first-unread id and own-send range are the same as before.
…one layout-phase inset

Both read the margin through ComposerInsets while laying out, so a keyboard animation frame moves the jump button and the $ button together. The old composition-time end padding in MessageList is gone.
@github-actions github-actions Bot added type: feature New functionality area: ui Compose UI, theme, components, resources and removed type: feature New functionality labels Oct 14, 2026
@bmc08gt bmc08gt self-assigned this Oct 15, 2026
The button built its own blur, skipped the composer's container fill and rim gradient, and carried a shadow the other surfaces don't have. It now uses a shared chatGlassMaterial() with the message bar, the $ button and the join-requests card.
@github-actions github-actions Bot added the type: feature New functionality label Oct 15, 2026
@bmc08gt
bmc08gt merged commit 888b35b into code/cash Oct 15, 2026
2 of 3 checks passed
bmc08gt added a commit that referenced this pull request Oct 15, 2026
#1783 made opening a chat wait on newestPageLoads().first { }. The test's
relaxed ChatCoordinator returns a Flow that completes empty, so first threw
NoSuchElementException and both tests failed on code/cash. Stub it with a
MutableSharedFlow, as ChatOpenTranscriptTest does.
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