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
@@ -102,6 +102,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
isViewSourceEnabled: appSettings.viewSourceEnabled,
areThreadsEnabled: appSettings.threadsEnabled,
linkPreviewsEnabled: appSettings.linkPreviewsEnabled,
jumpToReadMarkerEnabled: appSettings.jumpToReadMarkerEnabled,
hasPredecessor: roomProxy.predecessorRoom != nil,
pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs,
emojiProvider: emojiProvider,
@@ -179,6 +180,18 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
scrollToBottom()
case .scrollToFirstItemForCurrentDate:
state.timelineState.scrollToFirstItemForDatePublisher.send()
case .scrollToReadMarker:
Task { await scrollToReadMarker() }
case .markAllAsRead:
state.bindings.hasNewMessagesAtBottom = false
Task {
_ = await roomProxy.markAsRead(receiptType: .fullyRead)
// Clear locally so the jump-to-unread button hides without waiting
// for the SDK to push a refreshed RoomInfo. Doing this after the
// await means any stale RoomInfo update racing the mark-as-read
// call has already landed and can't overwrite this clear.
state.timelineState.fullyReadEventID = nil
}
case .displayTimelineItemMenu(let itemID):
timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID)
case .handleTimelineItemMenuAction(let itemID, let action):
@@ -421,6 +434,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.pinnedEventIDs = roomInfo.pinnedEventIDs
state.isDM = roomInfo.isDM
state.timelineState.fullyReadEventID = roomInfo.fullyReadEventID
if let powerLevels = roomInfo.powerLevels {
state.canCurrentUserSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
@@ -524,7 +538,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
.store(in: &cancellables)
}
func viewInRoomTimeline(eventID: String) async {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event):
@@ -551,6 +565,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings.$threadsEnabled
.weakAssign(to: \.state.areThreadsEnabled, on: self)
.store(in: &cancellables)
appSettings.$jumpToReadMarkerEnabled
.weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self)
.store(in: &cancellables)
userSession.clientProxy.timelineMediaVisibilityPublisher
.removeDuplicates()
@@ -647,7 +665,28 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
focusLive()
}
}
private func scrollToReadMarker() async {
// Primary: SDK has materialised the virtual ReadMarker. Smooth in-window scroll.
if let readMarkerID = state.timelineState.readMarkerUniqueID {
state.timelineState.scrollToReadMarkerPublisher.send(readMarkerID)
return
}
// Fallback: mirror the same-room permalink flow exactly
// (RoomFlowCoordinator.handleChildEventRoute lines 223-258): pre-fetch the
// event first to prime the SDK's event cache, then focus on it via the
// same focusOnEvent path permalinks use.
guard let fullyReadEventID = state.timelineState.fullyReadEventID else { return }
switch await roomProxy.loadOrFetchEventDetails(for: fullyReadEventID) {
case .success:
await focusOnEvent(eventID: fullyReadEventID)
case .failure:
displayErrorToast(L10n.errorMessageNotFound)
}
}
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
guard appMediator.appState == .active else { return }
@@ -842,8 +881,11 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
if isSwitchingTimelines {
state.timelineState.isSwitchingTimelines = true
}
updateHasNewMessagesAtBottom(with: timelineItemsDictionary)
state.timelineState.itemsDictionary = timelineItemsDictionary
state.timelineState.recomputeReadMarkerUniqueID()
}
private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
@@ -876,6 +918,27 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
&& otherEventTimelineItem.timestamp.timeIntervalSince(eventTimelineItem.timestamp) < 5 * 60 // As does the passage of time.
}
/// Sets `hasNewMessagesAtBottom` to `true` when newer items arrive while the user is scrolled
/// up in a live timeline. Skips initial load and timeline switches.
private func updateHasNewMessagesAtBottom(with newTimelineItems: OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>) {
guard state.jumpToReadMarkerEnabled,
state.timelineState.isLive,
!state.timelineState.isSwitchingTimelines,
!state.bindings.isScrolledToBottom,
!state.bindings.hasNewMessagesAtBottom else {
return
}
let oldDictionary = state.timelineState.itemsDictionary
guard !oldDictionary.isEmpty,
!newTimelineItems.isEmpty,
oldDictionary.keys.last != newTimelineItems.keys.last else {
return
}
state.bindings.hasNewMessagesAtBottom = true
}
// MARK: - Direct chats logics
private let inviteLoadingIndicatorID = UUID().uuidString