Timeline: add a Jump to Unread button and a new-message badge on the scroll-to-bottom button. (#5506)

* Timeline: add a Jump to Unread button and a new-message badge on the scroll-to-bottom button.

* Rename scrollToFirstUnread to scrollToReadMarker for consistency with isReadMarkerVisible.

* Cache readMarkerUniqueID and unreadMessageCount as stored properties on TimelineState.

* TimelineViewModel: rename newTimelineItems and drop redundant ordering comment.

* Use Compound chevron icons in TimelineScrollButton.

* Fix TimelineScrollButton layout at large dynamic type sizes.

* Add a trailingAccessory slot to topBanners and use it to dodge banners with the jump-to-read-marker button.

* Put jump-to-read-marker behind a developer-options feature flag.

* Fine-tune TimelineScrollButton sizing and badge colour.

* Remove TimelineScrollButton chevron offset that was compensating for SF Symbol centering.

* Drop the new-messages count, replace with a presence dot.

* Move both timeline scroll buttons to bottom-right and revert topBanners trailingAccessory slot.

* Position TimelineScrollButton badge above for up direction and below for down direction.

* Add long-press mark-as-read pill to the timeline scroll buttons

* Read isSwitchingTimelines from state in updateHasNewMessagesAtBottom.

* Use assertionFailure when scrollToReadMarker is called with no read marker.

* Add TimelineScrollButton preview snapshots.

* (fix) design and various self-review issues

* (fix) suppress scroll button tap after long-press

* (feat) fall back to RoomInfo.fullyReadEventID for jump-to-unread

* (fix) hide jump-to-unread immediately after mark-as-read

* (fix) align with newer matrix-rust-sdk surface

* Bump the RustSDK to v26.05.20

---------

Signed-off-by: Daniel Anderson <daniel.anderson@toptal.com>
Co-authored-by: Stefan Ceriu <stefan.ceriu@gmail.com>
This commit is contained in:
Danny
2026-05-21 06:03:32 -06:00
committed by GitHub
parent ccec2dff6e
commit eae464ecd7
29 changed files with 585 additions and 87 deletions
@@ -58,6 +58,8 @@ enum TimelineViewAction {
case paginateForwards
case scrollToBottom
case scrollToFirstItemForCurrentDate
case scrollToReadMarker
case markAllAsRead
case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
@@ -119,6 +121,7 @@ struct TimelineViewState: BindableState {
var isViewSourceEnabled: Bool
var areThreadsEnabled: Bool
var linkPreviewsEnabled: Bool
var jumpToReadMarkerEnabled: Bool
let hasPredecessor: Bool
@@ -151,7 +154,16 @@ struct TimelineViewState: BindableState {
struct TimelineViewStateBindings {
var isScrolledToBottom = true
/// Whether the read marker (NEW banner) is currently visible in the timeline viewport.
/// Used to hide the jump-to-unread button once the user has scrolled to the read marker.
var isReadMarkerVisible = false
/// Whether new messages have arrived while the user is scrolled away from the bottom of a
/// live timeline. Drives the presence dot on the scroll-to-bottom button and resets when the
/// user returns to the bottom.
var hasNewMessagesAtBottom = false
/// The timestamp of the topmost visible item, used to drive the floating date badge while scrolling.
var floatingDate: Date?
@@ -248,17 +260,36 @@ struct TimelineState {
/// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var scrollToFirstItemForDatePublisher = PassthroughSubject<Void, Never>()
var scrollToReadMarkerPublisher = PassthroughSubject<TimelineItemIdentifier.UniqueID, Never>()
var itemsDictionary = OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>()
var uniqueIDs: [TimelineItemIdentifier.UniqueID] {
itemsDictionary.keys.elements
}
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
/// The unique ID of the read marker (NEW banner) in the timeline, if present.
/// Recomputed by ``recomputeReadMarkerUniqueID()`` whenever ``itemsDictionary`` changes.
private(set) var readMarkerUniqueID: TimelineItemIdentifier.UniqueID?
/// The user's `m.fully_read` event ID, pushed from `RoomInfo`. Used as a fallback
/// signal when ``readMarkerUniqueID`` is nil because the marker event isn't paginated
/// into the loaded timeline window.
var fullyReadEventID: String?
/// Recomputes ``readMarkerUniqueID`` from ``itemsDictionary``. Call after assigning a new
/// value to ``itemsDictionary``.
mutating func recomputeReadMarkerUniqueID() {
readMarkerUniqueID = itemsDictionary.first { _, viewState in
if case .readMarker = viewState.type { return true }
return false
}?.key
}
func hasLoadedItem(with eventID: String) -> Bool {
itemViewStates.contains { $0.identifier.eventID == eventID }
}
@@ -270,6 +301,39 @@ enum ScrollDirection: Equatable {
}
extension TimelineViewState {
/// The user is at the bottom of a live timeline (no jump-to-bottom button needed).
var isAtBottomAndLive: Bool {
bindings.isScrolledToBottom && timelineState.isLive
}
/// Whether the scroll-to-bottom button should be shown: the user is scrolled away from the
/// bottom of a live timeline.
var shouldShowScrollToBottomButton: Bool {
!isAtBottomAndLive
}
/// Whether the jump-to-read-marker button should be shown.
///
/// Primary path: the SDK has materialised a virtual `ReadMarker` item in the
/// loaded timeline window. Show while it isn't visible in the viewport.
///
/// Fallback path: the marker event is older than the loaded window, so no
/// virtual item exists. Show only when items have actually loaded AND the
/// marker event isn't among them — if the marker IS loaded but no virtual
/// item was inserted, the user is caught up (no events newer than the marker)
/// and there's nothing to jump to.
var shouldShowJumpToReadMarker: Bool {
guard jumpToReadMarkerEnabled else { return false }
if timelineState.readMarkerUniqueID != nil {
return !bindings.isReadMarkerVisible
}
guard let fullyReadEventID = timelineState.fullyReadEventID else {
return false
}
return !timelineState.itemsDictionary.isEmpty
&& !timelineState.hasLoadedItem(with: fullyReadEventID)
}
/// The string shown as the message preview.
///
/// This converts the formatted body to a plain string to remove formatting