mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -145,8 +145,14 @@ class TimelineTableViewController: UIViewController {
|
||||
}
|
||||
|
||||
@Binding private var isScrolledToBottom: Bool
|
||||
@Binding private var isReadMarkerVisible: Bool
|
||||
@Binding private var hasNewMessagesAtBottom: Bool
|
||||
@Binding private var floatingDate: Date?
|
||||
|
||||
|
||||
/// The unique ID of the read marker (NEW banner) currently in the timeline, if any.
|
||||
/// Updated by `TimelineViewRepresentable.updateUIViewController` whenever it changes.
|
||||
var readMarkerUniqueID: TimelineItemIdentifier.UniqueID?
|
||||
|
||||
/// A work item used to auto-hide the floating date badge after scrolling stops.
|
||||
private var floatingDateHideWorkItem: DispatchWorkItem?
|
||||
|
||||
@@ -179,13 +185,18 @@ class TimelineTableViewController: UIViewController {
|
||||
|
||||
init(coordinator: TimelineViewRepresentable.Coordinator,
|
||||
isScrolledToBottom: Binding<Bool>,
|
||||
isReadMarkerVisible: Binding<Bool>,
|
||||
hasNewMessagesAtBottom: Binding<Bool>,
|
||||
floatingDate: Binding<Date?>,
|
||||
scrollToBottomPublisher: PassthroughSubject<Void, Never>,
|
||||
scrollToFirstItemForDatePublisher: PassthroughSubject<Void, Never>) {
|
||||
scrollToFirstItemForDatePublisher: PassthroughSubject<Void, Never>,
|
||||
scrollToReadMarkerPublisher: PassthroughSubject<TimelineItemIdentifier.UniqueID, Never>) {
|
||||
self.coordinator = coordinator
|
||||
_isScrolledToBottom = isScrolledToBottom
|
||||
_isReadMarkerVisible = isReadMarkerVisible
|
||||
_hasNewMessagesAtBottom = hasNewMessagesAtBottom
|
||||
_floatingDate = floatingDate
|
||||
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
|
||||
@@ -215,6 +226,12 @@ class TimelineTableViewController: UIViewController {
|
||||
self?.scrollToFirstItemForCurrentDate()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
scrollToReadMarkerPublisher
|
||||
.sink { [weak self] uniqueID in
|
||||
self?.scrollToItem(uniqueID: uniqueID, animated: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
paginatePublisher
|
||||
.collect(.byTime(DispatchQueue.main, 0.1))
|
||||
@@ -383,8 +400,13 @@ class TimelineTableViewController: UIViewController {
|
||||
if isSwitchingTimelines {
|
||||
coordinator.send(viewAction: .hasSwitchedTimeline)
|
||||
}
|
||||
|
||||
// Re-evaluate after the snapshot has been applied so the new layout is reflected.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateReadMarkerVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Scrolls to the newest item in the timeline.
|
||||
private func scrollToNewestItem(animated: Bool) {
|
||||
guard !timelineItemsIDs.isEmpty else {
|
||||
@@ -403,6 +425,16 @@ class TimelineTableViewController: UIViewController {
|
||||
scrollDirectionPublisher.send(.top)
|
||||
}
|
||||
|
||||
/// Scrolls to the item with the corresponding unique ID. Used to jump to virtual items like
|
||||
/// the read marker that have no event ID. Positions the item near the middle of the viewport
|
||||
/// so the user can see what's above and below the marker.
|
||||
private func scrollToItem(uniqueID: TimelineItemIdentifier.UniqueID, animated: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let indexPath = dataSource?.indexPath(for: uniqueID) else { return }
|
||||
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls to the item with the corresponding event ID if loaded in the timeline.
|
||||
private func scrollToItem(eventID: String, animated: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in // Fixes #2805
|
||||
@@ -441,6 +473,27 @@ class TimelineTableViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the `isReadMarkerVisible` binding based on whether the read marker is currently
|
||||
/// on screen or below the viewport (already scrolled past).
|
||||
///
|
||||
/// In the flipped table view, "above the viewport" means a higher index path. The marker is
|
||||
/// considered "visible or below" iff its index path ≤ the maximum visible index path.
|
||||
private func updateReadMarkerVisibility() {
|
||||
let isVisible: Bool = {
|
||||
guard let readMarkerUniqueID,
|
||||
let readMarkerIndexPath = dataSource?.indexPath(for: readMarkerUniqueID),
|
||||
let visibleIndexPaths = tableView.indexPathsForVisibleRows,
|
||||
let maxVisibleIndexPath = visibleIndexPaths.max() else {
|
||||
return false
|
||||
}
|
||||
return readMarkerIndexPath <= maxVisibleIndexPath
|
||||
}()
|
||||
|
||||
if isReadMarkerVisible != isVisible {
|
||||
isReadMarkerVisible = isVisible
|
||||
}
|
||||
}
|
||||
|
||||
private func sendLastVisibleItemReadReceipt() {
|
||||
// Find the last visible timeline item and send a read receipt for it
|
||||
guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else {
|
||||
@@ -467,17 +520,22 @@ extension TimelineTableViewController: UITableViewDelegate {
|
||||
// Dispatch to fix runtime warning about making changes during a view update.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
let isScrolledToBottom = scrollView.contentOffset.y <= 0
|
||||
|
||||
|
||||
// Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling.
|
||||
if self.isScrolledToBottom != isScrolledToBottom {
|
||||
self.isScrolledToBottom = isScrolledToBottom
|
||||
if isScrolledToBottom, self.hasNewMessagesAtBottom {
|
||||
self.hasNewMessagesAtBottom = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !isScrolledToBottom {
|
||||
updateFloatingDate()
|
||||
}
|
||||
|
||||
updateReadMarkerVisibility()
|
||||
}
|
||||
|
||||
// We never want the table view to be fully at the bottom to allow the status bar tap to work properly
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,9 +83,12 @@ struct TimelineViewRepresentable: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> TimelineTableViewController {
|
||||
TimelineTableViewController(coordinator: context.coordinator,
|
||||
isScrolledToBottom: $viewModelContext.isScrolledToBottom,
|
||||
isReadMarkerVisible: $viewModelContext.isReadMarkerVisible,
|
||||
hasNewMessagesAtBottom: $viewModelContext.hasNewMessagesAtBottom,
|
||||
floatingDate: $viewModelContext.floatingDate,
|
||||
scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher,
|
||||
scrollToFirstItemForDatePublisher: viewModelContext.viewState.timelineState.scrollToFirstItemForDatePublisher)
|
||||
scrollToFirstItemForDatePublisher: viewModelContext.viewState.timelineState.scrollToFirstItemForDatePublisher,
|
||||
scrollToReadMarkerPublisher: viewModelContext.viewState.timelineState.scrollToReadMarkerPublisher)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) {
|
||||
@@ -127,7 +130,10 @@ struct TimelineViewRepresentable: UIViewControllerRepresentable {
|
||||
if tableViewController.hideTimelineMedia != context.viewState.hideTimelineMedia {
|
||||
tableViewController.hideTimelineMedia = context.viewState.hideTimelineMedia
|
||||
}
|
||||
|
||||
if tableViewController.readMarkerUniqueID != context.viewState.timelineState.readMarkerUniqueID {
|
||||
tableViewController.readMarkerUniqueID = context.viewState.timelineState.readMarkerUniqueID
|
||||
}
|
||||
|
||||
if tableViewController.typingMembers.members != context.viewState.typingMembers {
|
||||
tableViewController.setTypingMembers(context.viewState.typingMembers)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user