From eae464ecd76f24394e98436456dfebb90b2fda9b Mon Sep 17 00:00:00 2001 From: Danny Date: Thu, 21 May 2026 06:03:32 -0600 Subject: [PATCH] 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 Co-authored-by: Stefan Ceriu --- .../Sources/GeneratedAccessibilityTests.swift | 4 + ElementX.xcodeproj/project.pbxproj | 14 +- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../en.lproj/Untranslated.strings | 1 + .../Application/Settings/AppSettings.swift | 6 +- .../Generated/Strings+Untranslated.swift | 2 + .../Mocks/Generated/GeneratedMocks.swift | 1 + .../SwiftUI/Views/TimelineScrollButton.swift | 141 ++++++++++++++++++ .../Views/TimelineScrollToBottomButton.swift | 35 ----- .../SwiftUI/Views/TopBannerModifier.swift | 2 +- .../TestablePreviewsDictionary.swift | 1 + .../Screens/RoomScreen/View/RoomScreen.swift | 120 ++++++++++++++- .../DeveloperOptionsScreenModels.swift | 2 + .../View/DeveloperOptionsScreen.swift | 7 +- .../View/ThreadTimelineScreen.swift | 6 +- .../Screens/Timeline/TimelineModels.swift | 74 ++++++++- .../TimelineTableViewController.swift | 72 ++++++++- .../Screens/Timeline/TimelineViewModel.swift | 69 ++++++++- .../Screens/Timeline/View/TimelineView.swift | 10 +- .../Sources/Services/Room/RoomInfoProxy.swift | 4 + .../Services/Room/RoomInfoProxyProtocol.swift | 1 + .../Services/Spaces/SpaceRoomListProxy.swift | 18 +-- .../Sources/GeneratedPreviewTests.swift | 8 + .../timelineScrollButton.iPad-en-GB-0.png | 3 + .../timelineScrollButton.iPad-pseudo-0.png | 3 + .../timelineScrollButton.iPhone-en-GB-0.png | 3 + .../timelineScrollButton.iPhone-pseudo-0.png | 3 + UnitTests/Sources/TimelineStateTests.swift | 54 +++++++ project.yml | 2 +- 29 files changed, 585 insertions(+), 87 deletions(-) create mode 100644 ElementX/Sources/Other/SwiftUI/Views/TimelineScrollButton.swift delete mode 100644 ElementX/Sources/Other/SwiftUI/Views/TimelineScrollToBottomButton.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-pseudo-0.png create mode 100644 UnitTests/Sources/TimelineStateTests.swift diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index b090d8304..e0c41768a 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -795,6 +795,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "TimelineReplyView_Previews") } + func testTimelineScrollButton() async throws { + try await performAccessibilityAudit(named: "TimelineScrollButton_Previews") + } + func testTimelineStartRoomTimelineView() async throws { try await performAccessibilityAudit(named: "TimelineStartRoomTimelineView_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index c0a878094..8ce297ce1 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -254,6 +254,7 @@ 28426806399E3659BEBC5918 /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB6ED50FE104992419310EEB /* NotificationHandler.swift */; }; 288408E6151D7BD3EBAA073A /* RoomScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B343C5255FB408DDE853CFDF /* RoomScreenHook.swift */; }; 28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */; }; + 291A3E0EECD496D0FCC2ADAA /* TimelineScrollButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4E14E3688FF3532456A656 /* TimelineScrollButton.swift */; }; 2932570AA418974979D16DED /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6DE144D887A254F4CAF203 /* UserPreference.swift */; }; 29491EE7AE37E239E839C5A3 /* LocationSharingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEBF0E59F25E842EDB6FD11 /* LocationSharingScreenModels.swift */; }; 2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; }; @@ -700,6 +701,7 @@ 756EA0D663261889EF64E6D4 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */; }; 7573D682F089205F7F1D96CF /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; }; 757862045774A0F458357E19 /* RoomThreadListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */; }; + 75E55A763FE8BA64FB8513E4 /* TimelineStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F968FDB68F9D4CB9010FED70 /* TimelineStateTests.swift */; }; 75ED4B73983228BB6922CE3C /* KnockRequestsListScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5C217DD0749EC709EED028 /* KnockRequestsListScreenViewModelProtocol.swift */; }; 761EA50B2619307AB30891B8 /* PhishingDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB07F03461023BC39C730922 /* PhishingDetector.swift */; }; 7624B61D0A3EFEC69C666609 /* SpaceAddRoomsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB74D407F0E413E7593B369 /* SpaceAddRoomsScreenViewModelTests.swift */; }; @@ -1517,7 +1519,6 @@ FCF95603F1D056B1B106A415 /* AdvancedSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E2B20431F890ED64255CA1 /* AdvancedSettingsScreenViewModelProtocol.swift */; }; FD19654C18E7C6D8AC79408D /* libMatrixRustSDKMocks.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */; }; FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; }; - FD439E183A48BE871AEEFAEA /* TimelineScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10765FBC83B34A3BC4ADB23 /* TimelineScrollToBottomButton.swift */; }; FD573B5D665824EB79EABF06 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */; }; FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; }; FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; }; @@ -2151,6 +2152,7 @@ 5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManagerProtocol.swift; sourceTree = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = ""; }; + 5C4E14E3688FF3532456A656 /* TimelineScrollButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollButton.swift; sourceTree = ""; }; 5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSharingBannerView.swift; sourceTree = ""; }; 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; 5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTestsAppCoordinator.swift; sourceTree = ""; }; @@ -2896,7 +2898,6 @@ E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterModels.swift; sourceTree = ""; }; E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = ""; }; E0FF9CB3EFA753277291F609 /* EncryptionResetScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenCoordinator.swift; sourceTree = ""; }; - E10765FBC83B34A3BC4ADB23 /* TimelineScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollToBottomButton.swift; sourceTree = ""; }; E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = ""; }; E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = ""; }; E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = ""; }; @@ -3040,6 +3041,7 @@ F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = ""; }; F8CCF9A924521DECA44778C4 /* AppLockSetupBiometricsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreen.swift; sourceTree = ""; }; F91AC45CDDF85E946391CA5C /* RoomThreadListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenViewModel.swift; sourceTree = ""; }; + F968FDB68F9D4CB9010FED70 /* TimelineStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStateTests.swift; sourceTree = ""; }; F9E543072DE58E751F028998 /* TimelineProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxy.swift; sourceTree = ""; }; FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = ""; }; FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = ""; }; @@ -3880,7 +3882,7 @@ CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */, A8558D41DD4B553A752C868A /* StackedAvatarsView.swift */, 1A786DE47C0DBCE0CD163193 /* StopButton.swift */, - E10765FBC83B34A3BC4ADB23 /* TimelineScrollToBottomButton.swift */, + 5C4E14E3688FF3532456A656 /* TimelineScrollButton.swift */, 16D353E10A64172D863769BF /* TombstonedAvatarImage.swift */, 510DA63AB273A68D659CEC95 /* ToolbarButton.swift */, 78483F0143E185EDC6ECD741 /* TopBannerModifier.swift */, @@ -5018,6 +5020,7 @@ 9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */, ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */, 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */, + F968FDB68F9D4CB9010FED70 /* TimelineStateTests.swift */, 6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */, 76310030C831D4610A705603 /* URLComponentsTests.swift */, 3AB34956C87731AB094DB33A /* URLTests.swift */, @@ -8003,6 +8006,7 @@ 0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */, C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */, F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */, + 75E55A763FE8BA64FB8513E4 /* TimelineStateTests.swift in Sources */, 2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */, 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */, AF33B9044498211C3D82F1E1 /* UNTextInputNotificationResponse+Creator.swift in Sources */, @@ -9079,7 +9083,7 @@ 8446C2A7ECEFDA79F622725F /* TimelineReactionsView.swift in Sources */, 4DAEE2468669848B6C9F55B4 /* TimelineReadReceiptsView.swift in Sources */, 6EB46C92ECFEAE71959D91D2 /* TimelineReplyView.swift in Sources */, - FD439E183A48BE871AEEFAEA /* TimelineScrollToBottomButton.swift in Sources */, + 291A3E0EECD496D0FCC2ADAA /* TimelineScrollButton.swift in Sources */, 9FBE1FB20171012260A32492 /* TimelineSenderAvatarView.swift in Sources */, C4FE0E11A907C8999F92D5A8 /* TimelineStartRoomTimelineItem.swift in Sources */, 785613C0C092B532198EB3BB /* TimelineStartRoomTimelineView.swift in Sources */, @@ -10181,7 +10185,7 @@ repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 26.05.18; + version = 26.05.20; }; }; 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5011872c7..3b3704f68 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "29630e4198750ff70c84dd9ae8d634528fa2551a48536caca7c538a2fe40a243", + "originHash" : "03817e609ee3e9e372d9320f569c830d82dd6dd0f4455b29822903349a85c61e", "pins" : [ { "identity" : "compound-design-tokens", @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "0e7dd117d013f2e2305552b68c7ad925c4421d0e", - "version" : "26.5.18" + "revision" : "5dd7f23990c9568f577152a932ed03111d8d2551", + "version" : "26.5.20" } }, { diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 49ef1d2d3..c02386764 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -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."; diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index f8770b903..19192c69d 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -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 diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 1a40d4028..f54225070 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -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? diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 452cf5e20..389183de9 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -16448,6 +16448,7 @@ class RoomInfoProxyMock: RoomInfoProxyProtocol, @unchecked Sendable { set(value) { underlyingUnreadMentionsCount = value } } var underlyingUnreadMentionsCount: UInt! + var fullyReadEventID: String? var pinnedEventIDs: Set { get { return underlyingPinnedEventIDs } set(value) { underlyingPinnedEventIDs = value } diff --git a/ElementX/Sources/Other/SwiftUI/Views/TimelineScrollButton.swift b/ElementX/Sources/Other/SwiftUI/Views/TimelineScrollButton.swift new file mode 100644 index 000000000..33267d302 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/TimelineScrollButton.swift @@ -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) + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/TimelineScrollToBottomButton.swift b/ElementX/Sources/Other/SwiftUI/Views/TimelineScrollToBottomButton.swift deleted file mode 100644 index 4a75072cb..000000000 --- a/ElementX/Sources/Other/SwiftUI/Views/TimelineScrollToBottomButton.swift +++ /dev/null @@ -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) - } -} diff --git a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift index ac795fd44..4f0bbd178 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift @@ -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 diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index fbd3084b6..7bb5cdaf5 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -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, diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 55d667527..c3cb83d94 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -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 { diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 9beb6bee5..9f0f2372f 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -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 } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 4ef9990fe..dc7c27f00 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -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") diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index 0aeb654d6..09fcb0e98 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -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 - } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 3ede2b35e..d296421dd 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -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() var scrollToFirstItemForDatePublisher = PassthroughSubject() - + var scrollToReadMarkerPublisher = PassthroughSubject() + var itemsDictionary = OrderedDictionary() - + 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 diff --git a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift index 82f0189e3..dc891ac6b 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift @@ -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, + isReadMarkerVisible: Binding, + hasNewMessagesAtBottom: Binding, floatingDate: Binding, scrollToBottomPublisher: PassthroughSubject, - scrollToFirstItemForDatePublisher: PassthroughSubject) { + scrollToFirstItemForDatePublisher: PassthroughSubject, + scrollToReadMarkerPublisher: PassthroughSubject) { 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 diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 0b6262640..6daf6a208 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -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) { + 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 diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index 7c06dfd57..f7ce7d472 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -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) } diff --git a/ElementX/Sources/Services/Room/RoomInfoProxy.swift b/ElementX/Sources/Services/Room/RoomInfoProxy.swift index 80ebf17d6..e8d14cac0 100644 --- a/ElementX/Sources/Services/Room/RoomInfoProxy.swift +++ b/ElementX/Sources/Services/Room/RoomInfoProxy.swift @@ -145,6 +145,10 @@ struct RoomInfoProxy: RoomInfoProxyProtocol { UInt(roomInfo.numUnreadMentions) } + var fullyReadEventID: String? { + roomInfo.fullyReadEventId + } + var pinnedEventIDs: Set { Set(roomInfo.pinnedEventIds) } diff --git a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift index feed35012..d487896d3 100644 --- a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift @@ -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 { get } var joinRule: JoinRule? { get } var historyVisibility: RoomHistoryVisibility { get } diff --git a/ElementX/Sources/Services/Spaces/SpaceRoomListProxy.swift b/ElementX/Sources/Services/Spaces/SpaceRoomListProxy.swift index e2f28e199..5346e8b31 100644 --- a/ElementX/Sources/Services/Spaces/SpaceRoomListProxy.swift +++ b/ElementX/Sources/Services/Spaces/SpaceRoomListProxy.swift @@ -31,29 +31,27 @@ class SpaceRoomListProxy: SpaceRoomListProxyProtocol { private let paginationStateHandle: TaskHandle let paginationStatePublisher: CurrentValuePublisher - 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(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 { diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index df5db5701..be1347561 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1587,6 +1587,14 @@ extension PreviewTests { } } + @Test + func timelineScrollButton() async throws { + AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. + for (index, preview) in TimelineScrollButton_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func timelineStartRoomTimelineView() async throws { AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-en-GB-0.png new file mode 100644 index 000000000..53ce8767a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:751792427bdb0932a9d72082a95c339932a09cfbd98ca2fd62b93e254c1e8000 +size 58245 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-pseudo-0.png new file mode 100644 index 000000000..84e32b132 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d6956c87ee050097079b8e1528f184c0e01b7f31069849f351f4596154658e1 +size 57970 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-en-GB-0.png new file mode 100644 index 000000000..7371e3358 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f636edba1250ae92b603f5c59dd484ec0483556a223a333211fae73b3edcda7 +size 33156 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-pseudo-0.png new file mode 100644 index 000000000..b0feb2b72 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineScrollButton.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:660b65377e724f9011f9598f0952d349c9f707f1e31bbb0fc20b075ccc26184b +size 32910 diff --git a/UnitTests/Sources/TimelineStateTests.swift b/UnitTests/Sources/TimelineStateTests.swift new file mode 100644 index 000000000..01e23ec41 --- /dev/null +++ b/UnitTests/Sources/TimelineStateTests.swift @@ -0,0 +1,54 @@ +// +// 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. +// + +@testable import ElementX +import OrderedCollections +import Testing + +struct TimelineStateTests { + // MARK: - Read marker + + @Test("Read marker unique ID is captured when present in the timeline") + func readMarkerUniqueIDPresent() { + var state = TimelineState() + state.itemsDictionary[.init("msg1")] = makeTextViewState(uniqueID: "msg1") + state.itemsDictionary[.init("read-marker")] = makeReadMarkerViewState(uniqueID: "read-marker") + state.itemsDictionary[.init("msg2")] = makeTextViewState(uniqueID: "msg2") + state.recomputeReadMarkerUniqueID() + + #expect(state.readMarkerUniqueID == .init("read-marker")) + } + + @Test("Read marker unique ID is nil when no marker exists") + func readMarkerUniqueIDAbsent() { + var state = TimelineState() + state.itemsDictionary[.init("msg1")] = makeTextViewState(uniqueID: "msg1") + state.itemsDictionary[.init("msg2")] = makeTextViewState(uniqueID: "msg2") + state.recomputeReadMarkerUniqueID() + + #expect(state.readMarkerUniqueID == nil) + } + + // MARK: - Helpers + + private func makeTextViewState(uniqueID: String) -> RoomTimelineItemViewState { + let item = TextRoomTimelineItem(id: .virtual(uniqueID: .init(uniqueID)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@sender:example.com"), + content: .init(body: "Test message")) + return RoomTimelineItemViewState(type: .text(item), groupStyle: .single) + } + + private func makeReadMarkerViewState(uniqueID: String) -> RoomTimelineItemViewState { + let item = ReadMarkerRoomTimelineItem(id: .virtual(uniqueID: .init(uniqueID))) + return RoomTimelineItemViewState(type: .readMarker(item), groupStyle: .single) + } +} diff --git a/project.yml b/project.yml index 036bb906a..a55c70f7c 100644 --- a/project.yml +++ b/project.yml @@ -74,7 +74,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/element-hq/matrix-rust-components-swift - exactVersion: 26.05.18 + exactVersion: 26.05.20 # path: ../matrix-rust-sdk Compound: path: compound-ios