Commit aaaf0df
authored
Add scroll-to-first-unread pill to the message view (#6409)
* Surface unreadLabel on MessageListState
Adds an UnreadLabel field on MessageListState so UI layers can react to the
sticky unread-boundary signal without depending on the live unreadCount, which
collapses to 0 once the SDK auto-marks the latest message as read on chat
open. The new field mirrors MessageListController.unreadLabelState via a
collector.
Declares UnreadLabel as a typealias in the state package
(state/messages/list/UnreadLabel.kt) pointing at MessageListController's
nested data class, so MessageListState references the type from its own
package without reaching into feature/messages/list. Both names resolve to
the same JVM class — existing consumers of MessageListController.UnreadLabel
keep compiling.
* Expose disableUnreadLabelButton on Compose MessageListViewModel
Lets the UI dismiss the floating unread-label button (e.g. from a close
affordance on the scroll-to-first-unread pill) without affecting the inline
unread separator.
* Add ScrollToFirstUnreadButton ChatComponentFactory slot
Introduces the floating pill primitive used to jump to the first unread message
when the unread boundary sits outside the viewport. The pill exposes two
distinct interactions: tapping the label area scrolls to the boundary, while
tapping the trailing close icon dismisses the pill without scrolling.
Adds the ScrollToFirstUnreadButtonParams holder, the ChatComponentFactory slot,
the default composable, and the supporting string resources. The slot is
unwired in this commit; the wiring lands in the follow-up.
* Wire scroll-to-first-unread pill into the message view
Renders the floating pill inside DefaultMessagesHelperContent using the sticky
unread label exposed on MessageListState. Visibility derives from
unreadLabel.buttonVisibility plus whether the inline unread separator is in
the visible viewport, so the pill stays correct even after the SDK
auto-marks the latest visible message as read on chat open.
Tap on the pill body invokes MessageListViewModel.scrollToFirstUnreadMessage
(loading the boundary if needed); tap on the close affordance invokes
MessageListViewModel.disableUnreadLabelButton. Both actions are exposed as
trailing defaulted callbacks on the public MessageList overloads so that
state-only consumers can opt in.
The visibility derivedState observes only lazyListState.layoutInfo (a State)
and re-keys on unreadSeparatorIndex; buttonVisibility is read on each
recomposition from the parameter so it never relies on a stale capture.
* Make unread-label button dismissal sticky against read-state changes
disableUnreadLabelButton() flipped buttonVisibility on the current label, but
observeUnreadLabelState recomputes the label whenever lastReadMessageId
changes and the calculator restored buttonVisibility=true. After
scrollToFirstUnreadMessage, the auto-mark-read on the now-visible boundary
triggered exactly that recomputation and the pill returned.
Also tryEmit(false) on showUnreadButtonState so the suppression flows through
the calculator on subsequent recomputes. The suppression is per-controller, so
it resets when the user leaves and re-enters the channel — matching the Figma
spec.
A regression test in MessageListControllerTests locks the new contract: after
disableUnreadLabelButton, pushing a new lastReadMessageId on channelState.read
must not flip buttonVisibility back to true.
* Tidy ScrollToFirstUnreadButton: tokenise stroke, simplify modifiers
Cleanup-only follow-up to the pill UI:
- Replace the hardcoded 1.dp stroke with StreamTokens.borderStrokeSubtle so
the border respects the design-system token.
- Pass role=Role.Button directly to clickable instead of layering a separate
semantics modifier; the role lands on the same semantics node either way.
- Split each four-edge padding into a vertical+horizontal pair for symmetry
with the rest of the codebase's padding patterns.
No behaviour or API change.
* Document unreadLabelState contract and Compose-friendly alternative
Expands the KDoc on MessageListController.unreadLabelState to describe its
stickiness, the dismissal contract, and to point Compose-style consumers at
MessageListState.unreadLabel (the aggregated mirror) so granular subscribers
and UI consumers each pick the better entry point.
* Add role to internal Modifier.clickable; reuse in pill
The internal Modifier.clickable helper centralises the SDK's clickable surface
(explicit Material 3 ripple, optional bounded clipping). The pill was
side-stepping it by calling foundation's clickable directly, drifting from the
codebase convention. Add a defaulted role parameter to the helper so the pill
can ride the shared ripple while still announcing Role.Button.
Two pre-existing trailing-lambda call sites (MediaGalleryPage,
MediaGalleryPhotosMenu) import both the foundation and internal clickables;
the new role parameter makes the simplified ".clickable { … }" form
ambiguous between the two. Disambiguate by passing bounded = true (foundation
has no bounded), which preserves the existing ripple-bearing behaviour.
* Add vertical separator between label and close icon in pill
Mirrors the Figma "Message View — Opened With Unread Messages" mock, which
shows a thin vertical rule between the unread count and the dismiss affordance.
Reuses the same border token already applied to the pill outline so the inner
rule and the outline read as a single design.
* Test: ScrollToFirstUnreadButton snapshot
Three Paparazzi snapshots covering the unread-count surface area: 1, 9, and
999. Each renders both light and dark variants via snapshotWithDarkMode so the
divider, border, and ripple-bearing label area are all visible against the
themed background.
* Fix pill preview height and re-target the click testTag
Two related fixes on the pill composable:
- Bound the inner Row to Modifier.height(IntrinsicSize.Min). VerticalDivider
uses fillMaxHeight() internally, which was unbounded inside the Surface
preview and stretched the pill across the rendered surface. With
IntrinsicSize.Min, the divider's height resolves to the tallest
non-flexible child (the label row or the close icon).
- Move the Stream_ScrollToFirstUnreadButton testTag from the Surface to the
inner clickable Row. The Surface is not interactive, so a UI test that
finds the node by tag and performs a click would land on a non-clickable
node. With the tag on the click target, finding by tag and clicking now
invokes the scroll handler, mirroring the dismiss tag on the X icon.
* Address PR review: visibility key, null-label guard, test strictness
- Messages.kt: switch the pill's separator-visibility check to compare
LazyListItemInfo.key against the separator item's id, instead of comparing
the message-list index against the LazyList index. The two diverge when
non-message items (footerContent, the load-more indicator) precede
itemsIndexed in the column, which would mis-judge pill visibility.
- MessageListController.disableUnreadLabelButton: early-return when there is
no active unread label. The previous code emitted false on
showUnreadButtonState even on a no-op call (e.g. scrollToFirstUnreadMessage
with a null unreadLabelState, or the XML SDK's HideUnreadLabel event with
no active label), permanently latching the suppression for the controller's
lifetime and hiding any future unread pill.
- MessageListControllerTests: strengthen the post-read assertion to fail when
unreadLabelState becomes null. The previous safe-call chain silently passed
when the chain returned null, masking the regression the test is meant to
guard.
* Test: cover scroll-to-first-unread VM action and wired pill snapshot
Two coverage additions for the new unread-pill surface:
- MessageListViewModelTest gains a case for disableUnreadLabelButton on a
fixture with no active unread label, asserting state stays untouched. This
exercises the VM's delegating method and the controller's early-return
guard end-to-end.
- MessageListTest gains a snapshot variant rendering MessageList with an
active UnreadLabel and an inline UnreadSeparatorItemState. The snapshot
exercises the wired pill through the public state-only MessageList,
Messages.DefaultMessagesHelperContent, and the
ChatComponentFactory.ScrollToFirstUnreadButton slot in inspection mode.
* Move unread separator off-screen in pill snapshot fixture
The fixture put the separator at index 0 of messageItems, which with
reverseLayout = true sits at the bottom of the viewport. The pill hides
itself while the separator is visible, so the snapshot did not exercise
the pill. Place the separator at the end of the list so it stays above
the visible area.1 parent a7f2297 commit aaaf0df
21 files changed
Lines changed: 518 additions & 15 deletions
File tree
- stream-chat-android-compose
- api
- src
- main
- java/io/getstream/chat/android/compose
- ui
- attachments/preview/internal
- components/messages
- messages/list
- theme
- util
- viewmodel/messages
- res/values
- test
- kotlin/io/getstream/chat/android/compose
- ui
- components/messages
- messages
- viewmodel/messages
- snapshots/images
- stream-chat-android-ui-common
- api
- src
- main/kotlin/io/getstream/chat/android/ui/common
- feature/messages/list
- state/messages/list
- test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list
Lines changed: 41 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1516 | 1516 | | |
1517 | 1517 | | |
1518 | 1518 | | |
| 1519 | + | |
| 1520 | + | |
| 1521 | + | |
| 1522 | + | |
| 1523 | + | |
| 1524 | + | |
1519 | 1525 | | |
1520 | 1526 | | |
1521 | 1527 | | |
| |||
2091 | 2097 | | |
2092 | 2098 | | |
2093 | 2099 | | |
2094 | | - | |
| 2100 | + | |
2095 | 2101 | | |
2096 | 2102 | | |
2097 | 2103 | | |
| |||
2107 | 2113 | | |
2108 | 2114 | | |
2109 | 2115 | | |
2110 | | - | |
2111 | | - | |
| 2116 | + | |
| 2117 | + | |
2112 | 2118 | | |
2113 | 2119 | | |
2114 | 2120 | | |
| |||
3456 | 3462 | | |
3457 | 3463 | | |
3458 | 3464 | | |
| 3465 | + | |
3459 | 3466 | | |
3460 | 3467 | | |
3461 | 3468 | | |
| |||
3645 | 3652 | | |
3646 | 3653 | | |
3647 | 3654 | | |
| 3655 | + | |
3648 | 3656 | | |
3649 | 3657 | | |
3650 | 3658 | | |
| |||
5155 | 5163 | | |
5156 | 5164 | | |
5157 | 5165 | | |
5158 | | - | |
| 5166 | + | |
| 5167 | + | |
5159 | 5168 | | |
5160 | 5169 | | |
5161 | 5170 | | |
5162 | 5171 | | |
5163 | | - | |
5164 | | - | |
| 5172 | + | |
| 5173 | + | |
| 5174 | + | |
| 5175 | + | |
5165 | 5176 | | |
5166 | 5177 | | |
5167 | 5178 | | |
5168 | 5179 | | |
| 5180 | + | |
5169 | 5181 | | |
| 5182 | + | |
5170 | 5183 | | |
5171 | 5184 | | |
5172 | 5185 | | |
| |||
5683 | 5696 | | |
5684 | 5697 | | |
5685 | 5698 | | |
| 5699 | + | |
| 5700 | + | |
| 5701 | + | |
| 5702 | + | |
| 5703 | + | |
| 5704 | + | |
| 5705 | + | |
| 5706 | + | |
| 5707 | + | |
| 5708 | + | |
| 5709 | + | |
| 5710 | + | |
| 5711 | + | |
| 5712 | + | |
| 5713 | + | |
| 5714 | + | |
| 5715 | + | |
| 5716 | + | |
| 5717 | + | |
| 5718 | + | |
| 5719 | + | |
5686 | 5720 | | |
5687 | 5721 | | |
5688 | 5722 | | |
| |||
6776 | 6810 | | |
6777 | 6811 | | |
6778 | 6812 | | |
| 6813 | + | |
6779 | 6814 | | |
6780 | 6815 | | |
6781 | 6816 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
340 | 340 | | |
341 | 341 | | |
342 | 342 | | |
343 | | - | |
| 343 | + | |
344 | 344 | | |
345 | 345 | | |
346 | 346 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
188 | 188 | | |
189 | 189 | | |
190 | 190 | | |
191 | | - | |
| 191 | + | |
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
| |||
0 commit comments