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
@@ -1,3 +1,4 @@
"a11y_jump_to_unread" = "Jump to unread";
"crypto_history_sharing_confirm_invite_dialog_content" = "You currently don’t have any chats with these contacts. Confirm inviting them to this room before continuing.";
"crypto_history_sharing_confirm_invite_dialog_title" = "Invite new contacts to this room?";
"crypto_history_sharing_confirm_start_chat_dialog_content" = "You currently don’t have any chats with this person. Confirm inviting them before continuing.";
@@ -79,6 +79,7 @@ final class AppSettings {
case threadsEnabled
case roomThreadListEnabled
case linkPreviewsEnabled
case jumpToReadMarkerEnabled
case focusEventOnNotificationTap
case linkNewDeviceEnabled
case automaticBackPaginationEnabled
@@ -444,7 +445,10 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.linkPreviewsEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkPreviewsEnabled
@UserPreference(key: UserDefaultsKeys.jumpToReadMarkerEnabled, defaultValue: false, storageType: .userDefaults(store))
var jumpToReadMarkerEnabled
@UserPreference(key: UserDefaultsKeys.linkNewDeviceEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkNewDeviceEnabled
@@ -10,6 +10,8 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
internal enum UntranslatedL10n {
/// Jump to unread
internal static var a11yJumpToUnread: String { return UntranslatedL10n.tr("Untranslated", "a11y_jump_to_unread") }
/// You currently don’t have any chats with these contacts. Confirm inviting them to this room before continuing.
internal static var cryptoHistorySharingConfirmInviteDialogContent: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_invite_dialog_content") }
/// Invite new contacts to this room?
@@ -16448,6 +16448,7 @@ class RoomInfoProxyMock: RoomInfoProxyProtocol, @unchecked Sendable {
set(value) { underlyingUnreadMentionsCount = value }
}
var underlyingUnreadMentionsCount: UInt!
var fullyReadEventID: String?
var pinnedEventIDs: Set<String> {
get { return underlyingPinnedEventIDs }
set(value) { underlyingPinnedEventIDs = value }
@@ -0,0 +1,141 @@
//
// Copyright 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
/// A small floating circular button used by the timeline to jump to either the bottom (newest
/// item) or to the read marker (NEW banner). Optionally displays a presence dot.
struct TimelineScrollButton: View {
enum Direction {
case down
case up
}
let direction: Direction
let isHidden: Bool
let showsBadge: Bool
let onLongPress: (() -> Void)?
let callback: () -> Void
@ScaledMetric(relativeTo: .compound.bodyLG) private var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .compound.bodyLG) private var badgeDotSize: CGFloat = 12
@GestureState private var isPressing = false
/// Set when a long-press is recognized so the trailing touch-up doesn't also fire `callback()`.
@State private var consumedByLongPress = false
/// Padding around the chevron, balanced so the total circle stays ~36pt at default text size.
private let iconPadding: CGFloat = 6
/// Vertical offset that half-centers the badge dot on the circle's edge. Magnitude is half
/// the dot's diameter so half sits inside the circle, half outside. Sign is applied at the
/// call site based on direction.
private var badgeOffsetMagnitude: CGFloat {
badgeDotSize / 2
}
init(direction: Direction = .down,
isHidden: Bool = false,
showsBadge: Bool = false,
onLongPress: (() -> Void)? = nil,
callback: @escaping () -> Void) {
self.direction = direction
self.isHidden = isHidden
self.showsBadge = showsBadge
self.onLongPress = onLongPress
self.callback = callback
}
var body: some View {
if let onLongPress {
buttonContent
.scaleEffect(isPressing ? 1.05 : 1.0)
.compositingGroup()
.shadow(color: .black.opacity(isPressing ? 0.2 : 0), radius: isPressing ? 12 : 0)
.animation(.spring(response: 0.7), value: isPressing)
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
.updating($isPressing) { current, state, _ in state = current }
.onEnded { _ in
consumedByLongPress = true
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
onLongPress()
})
.simultaneousGesture(DragGesture(minimumDistance: 0)
.onEnded { _ in
// Touch-up signal. Defer the clear so the Button's tap action — which
// also fires on touch-up when the release was on-button — reads the
// flag while it's still set and suppresses the unwanted callback.
Task { @MainActor in consumedByLongPress = false }
})
} else {
buttonContent
}
}
private var buttonContent: some View {
Button {
if consumedByLongPress {
consumedByLongPress = false
return
}
callback()
} label: {
ZStack(alignment: direction == .down ? .bottom : .top) {
icon
if showsBadge {
badge
.offset(y: direction == .down ? badgeOffsetMagnitude : -badgeOffsetMagnitude)
}
}
}
.opacity(isHidden ? 0 : 1)
.allowsHitTesting(!isHidden)
.accessibilityHidden(isHidden)
.accessibilityLabel(direction == .down ? L10n.a11yJumpToBottom : UntranslatedL10n.a11yJumpToUnread)
.animation(.elementDefault, value: isHidden)
}
@ViewBuilder
private var icon: some View {
let chevron = CompoundIcon(direction == .down ? \.chevronDown : \.chevronUp,
size: .custom(iconSize),
relativeTo: .compound.bodyLG)
.foregroundStyle(.compound.iconPrimary)
.padding(iconPadding)
if #available(iOS 26, *) {
chevron
.glassEffect(.regular, in: Circle())
.overlay(Circle().stroke(Color.compound.borderInteractiveSecondary, lineWidth: 1))
} else {
chevron
.background(.regularMaterial, in: Circle())
.overlay(Circle().stroke(Color.compound.borderInteractiveSecondary, lineWidth: 1))
}
}
private var badge: some View {
Circle()
.fill(Color.compound.iconAccentPrimary)
.frame(width: badgeDotSize, height: badgeDotSize)
}
}
struct TimelineScrollButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 16) {
TimelineScrollButton(direction: .down) { }
TimelineScrollButton(direction: .down, showsBadge: true) { }
TimelineScrollButton(direction: .up) { }
TimelineScrollButton(direction: .up, showsBadge: true) { }
TimelineScrollButton(direction: .up, isHidden: true, showsBadge: true) { }
}
.padding()
.background(Color.compound.bgCanvasDefault)
.previewLayout(.sizeThatFits)
}
}
@@ -1,35 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
struct TimelineScrollToBottomButton: View {
let isVisible: Bool
let callback: () -> Void
var body: some View {
Button { callback() } label: {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
.fontWeight(.semibold)
.foregroundColor(.compound.iconSecondary)
.padding(13)
.offset(y: 1)
.background {
Circle()
.fill(Color.compound.iconOnSolidPrimary)
// Intentionally using system primary colour to get white/black.
.shadow(color: .primary.opacity(0.33), radius: 2.0)
}
.padding()
}
.opacity(isVisible ? 0.0 : 1.0)
.accessibilityHidden(isVisible)
.animation(.elementDefault, value: isVisible)
}
}
@@ -46,7 +46,7 @@ extension View {
func topBanner(_ banner: some View, isVisible: Bool, footer: some View = EmptyView()) -> some View {
topBanners([TopBannerLayer(banner, isVisible: isVisible)], footer: footer)
}
/// Overlays the given Z-axis banner slots at the top edge of this view.
/// Later items in the array are overlayed on top of earlier ones. Within
/// each slot, visible vertical banners are stacked in a VStack and slide
@@ -206,6 +206,7 @@ enum TestablePreviewsDictionary {
"TimelineReactionView_Previews" : TimelineReactionView_Previews.self,
"TimelineReadReceiptsView_Previews" : TimelineReadReceiptsView_Previews.self,
"TimelineReplyView_Previews" : TimelineReplyView_Previews.self,
"TimelineScrollButton_Previews" : TimelineScrollButton_Previews.self,
"TimelineStartRoomTimelineView_Previews" : TimelineStartRoomTimelineView_Previews.self,
"TimelineThreadSummaryView_Previews" : TimelineThreadSummaryView_Previews.self,
"TombstonedAvatarImage_Previews" : TombstonedAvatarImage_Previews.self,
@@ -17,6 +17,15 @@ struct RoomScreen: View {
let composerToolbar: ComposerToolbar
@Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled
enum MarkAsReadSource {
case up
case down
}
/// Which scroll button (if any) currently has the "Mark as read" pill displayed alongside it.
/// Set when the user long-presses one of the scroll buttons; the pill anchors to that button.
@State private var markAsReadSource: MarkAsReadSource?
init(context: RoomScreenViewModelType.Context,
timelineContext: TimelineViewModelType.Context,
composerToolbar: ComposerToolbar) {
@@ -27,11 +36,40 @@ struct RoomScreen: View {
var body: some View {
TimelineView(timelineContext: timelineContext)
.overlay(alignment: .bottomTrailing) {
TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) {
timelineContext.send(viewAction: .scrollToBottom)
.overlay {
// Sits below the bottom-trailing overlay in z-order, so taps on the pill or
// buttons still go to them; taps anywhere else dismiss the pill.
if markAsReadSource != nil {
Color.clear
.contentShape(Rectangle())
.onTapGesture { dismissMarkAsReadPill() }
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
}
.overlay(alignment: .bottomTrailing) {
VStack(alignment: .trailing, spacing: 16) {
HStack(spacing: 8) {
if markAsReadSource == .up {
markAsReadPill
.transition(.move(edge: .trailing).combined(with: .opacity))
}
jumpToReadMarkerButton
}
HStack(spacing: 8) {
if markAsReadSource == .down {
markAsReadPill
.transition(.move(edge: .trailing).combined(with: .opacity))
}
TimelineScrollButton(isHidden: !timelineContext.viewState.shouldShowScrollToBottomButton,
showsBadge: scrollToBottomShowsBadge,
onLongPress: scrollToBottomShowsBadge ? { revealMarkAsReadPill(source: .down) } : nil) {
dismissMarkAsReadPill()
timelineContext.send(viewAction: .scrollToBottom)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
}
}
.padding()
.animation(.elementDefault, value: markAsReadSource)
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.topBanners([
@@ -82,6 +120,9 @@ struct RoomScreen: View {
.overlay { loadingIndicator }
.alert(item: $context.alertInfo)
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.onChange(of: pillSourceButtonIsVisible) { _, isVisible in
if !isVisible { dismissMarkAsReadPill() }
}
.track(screen: .Room)
.sentryTrace("\(Self.self)")
}
@@ -129,11 +170,74 @@ struct RoomScreen: View {
private func onViewAllKnockRequests() {
context.send(viewAction: .viewKnockRequests)
}
private var isAtBottomAndLive: Bool {
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
@ViewBuilder
private var jumpToReadMarkerButton: some View {
if timelineContext.viewState.shouldShowJumpToReadMarker {
TimelineScrollButton(direction: .up,
showsBadge: true,
onLongPress: { revealMarkAsReadPill(source: .up) }) {
dismissMarkAsReadPill()
timelineContext.send(viewAction: .scrollToReadMarker)
}
}
}
private var markAsReadPill: some View {
Button {
timelineContext.send(viewAction: .markAllAsRead)
dismissMarkAsReadPill()
} label: {
markAsReadPillLabel
}
}
@ViewBuilder
private var markAsReadPillLabel: some View {
// Font scales with Dynamic Type via the Compound token; padding is a fixed point value
// so the pill grows with the text instead of growing twice over.
let label = Label {
Text(L10n.screenRoomlistMarkAsRead)
} icon: {
CompoundIcon(\.markAsRead, size: .medium, relativeTo: .compound.bodyLG)
}
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.padding(.horizontal, 20)
.padding(.vertical, 20)
if #available(iOS 26, *) {
label.glassEffect(.regular.interactive(), in: Capsule())
} else {
label.background(.regularMaterial, in: Capsule())
}
}
private func revealMarkAsReadPill(source: MarkAsReadSource) {
markAsReadSource = source
}
private func dismissMarkAsReadPill() {
markAsReadSource = nil
}
/// Whether the scroll button that the pill is anchored to is still being rendered.
/// Used to dismiss an orphaned pill when the source button gets hidden — without
/// this, the pill can render alongside an invisible button. Returns `true` when no
/// pill is shown so the `onChange` doesn't fire spuriously when the source clears.
private var pillSourceButtonIsVisible: Bool {
switch markAsReadSource {
case .up: timelineContext.viewState.shouldShowJumpToReadMarker
case .down: timelineContext.viewState.shouldShowScrollToBottomButton
case .none: true
}
}
/// Hide the new-messages dot when the jump-to-read-marker feature is disabled.
private var scrollToBottomShowsBadge: Bool {
timelineContext.viewState.jumpToReadMarkerEnabled
&& timelineContext.viewState.bindings.hasNewMessagesAtBottom
}
@ViewBuilder
private var composer: some View {
if context.viewState.hasSuccessor {
@@ -68,6 +68,8 @@ protocol DeveloperOptionsProtocol: AnyObject {
var knockingEnabled: Bool { get set }
var linkPreviewsEnabled: Bool { get set }
var jumpToReadMarkerEnabled: Bool { get set }
var linkNewDeviceEnabled: Bool { get set }
@@ -87,7 +87,12 @@ struct DeveloperOptionsScreen: View {
Text("Can leak the device IP address when loading link metadata.")
.foregroundStyle(.compound.textCriticalPrimary)
}
Toggle(isOn: $context.jumpToReadMarkerEnabled) {
Text("Jump to unread")
Text("Adds a button to jump to the read marker, plus a presence dot on the scroll-to-bottom button when new messages arrive while scrolled away.")
}
Toggle(isOn: $context.knockingEnabled) {
Text("Knocking")
Text("Ask to join rooms")
@@ -38,7 +38,7 @@ struct ThreadTimelineScreen: View {
.padding(.top, 13)
}
.overlay(alignment: .bottomTrailing) {
TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) {
TimelineScrollButton(isHidden: !timelineContext.viewState.shouldShowScrollToBottomButton) {
timelineContext.send(viewAction: .scrollToBottom)
}
}
@@ -77,8 +77,4 @@ struct ThreadTimelineScreen: View {
}
}
}
private var isAtBottomAndLive: Bool {
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
}
}
@@ -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)
}
@@ -145,6 +145,10 @@ struct RoomInfoProxy: RoomInfoProxyProtocol {
UInt(roomInfo.numUnreadMentions)
}
var fullyReadEventID: String? {
roomInfo.fullyReadEventId
}
var pinnedEventIDs: Set<String> {
Set(roomInfo.pinnedEventIds)
}
@@ -58,6 +58,7 @@ protocol RoomInfoProxyProtocol: BaseRoomInfoProxyProtocol {
var unreadMessagesCount: UInt { get }
var unreadNotificationsCount: UInt { get }
var unreadMentionsCount: UInt { get }
var fullyReadEventID: String? { get }
var pinnedEventIDs: Set<String> { get }
var joinRule: JoinRule? { get }
var historyVisibility: RoomHistoryVisibility { get }
@@ -31,29 +31,27 @@ class SpaceRoomListProxy: SpaceRoomListProxyProtocol {
private let paginationStateHandle: TaskHandle
let paginationStatePublisher: CurrentValuePublisher<SpaceRoomListPaginationState, Never>
init(_ spaceRoomList: SpaceRoomListProtocol) throws {
init(_ spaceRoomList: SpaceRoomListProtocol) async throws {
guard let spaceRoom = spaceRoomList.space() else { throw SpaceRoomListProxyError.missingSpace }
self.spaceRoomList = spaceRoomList
spaceServiceRoomSubject = .init(SpaceServiceRoom(spaceRoom: spaceRoom))
let paginationStateSubject = CurrentValueSubject<SpaceRoomListPaginationState, Never>(spaceRoomList.paginationState())
paginationStatePublisher = paginationStateSubject.asCurrentValuePublisher()
paginationStateHandle = spaceRoomList.subscribeToPaginationStateUpdates(listener: SDKListener { paginationState in
paginationStateSubject.send(paginationState)
})
spaceRoomsHandle = await spaceRoomList.subscribeToRoomUpdate(listener: SDKListener { [weak self] updates in
self?.handleUpdates(updates)
})
spaceServiceRoomHandle = spaceRoomList.subscribeToSpaceUpdates(listener: SDKListener { [weak self] spaceRoom in
guard let spaceRoom else { return }
self?.spaceServiceRoomSubject.send(SpaceServiceRoom(spaceRoom: spaceRoom))
})
Task {
spaceRoomsHandle = await spaceRoomList.subscribeToRoomUpdate(listener: SDKListener { [weak self] updates in
self?.handleUpdates(updates)
})
}
}
func paginate() async {