mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
Timeline: add a Jump to Unread button and a new-message badge on the scroll-to-bottom button. (#5506)
* Timeline: add a Jump to Unread button and a new-message badge on the scroll-to-bottom button. * Rename scrollToFirstUnread to scrollToReadMarker for consistency with isReadMarkerVisible. * Cache readMarkerUniqueID and unreadMessageCount as stored properties on TimelineState. * TimelineViewModel: rename newTimelineItems and drop redundant ordering comment. * Use Compound chevron icons in TimelineScrollButton. * Fix TimelineScrollButton layout at large dynamic type sizes. * Add a trailingAccessory slot to topBanners and use it to dodge banners with the jump-to-read-marker button. * Put jump-to-read-marker behind a developer-options feature flag. * Fine-tune TimelineScrollButton sizing and badge colour. * Remove TimelineScrollButton chevron offset that was compensating for SF Symbol centering. * Drop the new-messages count, replace with a presence dot. * Move both timeline scroll buttons to bottom-right and revert topBanners trailingAccessory slot. * Position TimelineScrollButton badge above for up direction and below for down direction. * Add long-press mark-as-read pill to the timeline scroll buttons * Read isSwitchingTimelines from state in updateHasNewMessagesAtBottom. * Use assertionFailure when scrollToReadMarker is called with no read marker. * Add TimelineScrollButton preview snapshots. * (fix) design and various self-review issues * (fix) suppress scroll button tap after long-press * (feat) fall back to RoomInfo.fullyReadEventID for jump-to-unread * (fix) hide jump-to-unread immediately after mark-as-read * (fix) align with newer matrix-rust-sdk surface * Bump the RustSDK to v26.05.20 --------- Signed-off-by: Daniel Anderson <daniel.anderson@toptal.com> Co-authored-by: Stefan Ceriu <stefan.ceriu@gmail.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = "<group>"; };
|
||||
5C4E14E3688FF3532456A656 /* TimelineScrollButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollButton.swift; sourceTree = "<group>"; };
|
||||
5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSharingBannerView.swift; sourceTree = "<group>"; };
|
||||
5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTestsAppCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -2896,7 +2898,6 @@
|
||||
E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterModels.swift; sourceTree = "<group>"; };
|
||||
E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = "<group>"; };
|
||||
E0FF9CB3EFA753277291F609 /* EncryptionResetScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
E10765FBC83B34A3BC4ADB23 /* TimelineScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollToBottomButton.swift; sourceTree = "<group>"; };
|
||||
E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = "<group>"; };
|
||||
E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -3040,6 +3041,7 @@
|
||||
F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = "<group>"; };
|
||||
F8CCF9A924521DECA44778C4 /* AppLockSetupBiometricsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreen.swift; sourceTree = "<group>"; };
|
||||
F91AC45CDDF85E946391CA5C /* RoomThreadListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
F968FDB68F9D4CB9010FED70 /* TimelineStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStateTests.swift; sourceTree = "<group>"; };
|
||||
F9E543072DE58E751F028998 /* TimelineProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxy.swift; sourceTree = "<group>"; };
|
||||
FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = "<group>"; };
|
||||
FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = "<group>"; };
|
||||
@@ -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" */ = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
@@ -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 }
|
||||
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:751792427bdb0932a9d72082a95c339932a09cfbd98ca2fd62b93e254c1e8000
|
||||
size 58245
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3d6956c87ee050097079b8e1528f184c0e01b7f31069849f351f4596154658e1
|
||||
size 57970
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f636edba1250ae92b603f5c59dd484ec0483556a223a333211fae73b3edcda7
|
||||
size 33156
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:660b65377e724f9011f9598f0952d349c9f707f1e31bbb0fc20b075ccc26184b
|
||||
size 32910
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user