Focus events on the live timeline when it has already loaded them

Opening a room on an event always built a detached timeline, even when the
live timeline already contained that event, e.g. when tapping the notification
for the newest message in a room.

The timeline controller now stays on the live timeline in that case, scrolling
to and highlighting the event, and only builds a detached timeline when the
event isn't loaded.
This commit is contained in:
Stefan Ceriu
2026-10-02 15:22:56 +03:00
parent 52b411e954
commit 9f141585a2
3 changed files with 87 additions and 1 deletions
+4
View File
@@ -675,6 +675,7 @@
6A54F52443EC52AC5CD772C0 /* JoinRoomScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 869A8A4632E511351BFE2EC4 /* JoinRoomScreen.swift */; };
6A5FDF9306CBD62C7EDDB552 /* CLLocationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C723327DC4A3093CD9675B27 /* CLLocationManagerMock.swift */; };
6A64546ABE648ED9E6DBB459 /* RemoteSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D186A6DB8FAC5C9D0E4D61 /* RemoteSettingsHook.swift */; };
6AA27078438185AEA8CDE2A1 /* TimelineControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE07EC9D34EF91795EDB3CC4 /* TimelineControllerTests.swift */; };
6AB306367E56A6F6DFA0E2FF /* RoomSummaryProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E441BA50705E6CEC89FE0 /* RoomSummaryProviderTests.swift */; };
6AC798F52571BE495E6AA1CE /* OAuthAccountSettingsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21819C86A022D6ADBA7A7A15 /* OAuthAccountSettingsPresenter.swift */; };
6AD722DD92E465E56D2885AB /* BugReportScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */; };
@@ -3275,6 +3276,7 @@
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = "<group>"; };
FDEDD4D2DE0646DA724985D5 /* QRCodeLoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenModels.swift; sourceTree = "<group>"; };
FDF73F49E6B6683F7E2D26F0 /* SecureBackupScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreenCoordinator.swift; sourceTree = "<group>"; };
FE07EC9D34EF91795EDB3CC4 /* TimelineControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineControllerTests.swift; sourceTree = "<group>"; };
FE1E6FAA3719E9B7A2D5510B /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = "<group>"; };
FE3AB4D399D39FBD78119011 /* AuthenticationStartScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreenModels.swift; sourceTree = "<group>"; };
FE5CD2993048222B64C45006 /* SDKListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKListener.swift; sourceTree = "<group>"; };
@@ -5343,6 +5345,7 @@
18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */,
6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */,
2CEBCB9676FCD1D0F13188DD /* StringTests.swift */,
FE07EC9D34EF91795EDB3CC4 /* TimelineControllerTests.swift */,
53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */,
9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */,
C98C243A90369929D669873D /* TimelineItemMenuActionProviderTests.swift */,
@@ -8464,6 +8467,7 @@
6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */,
1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */,
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */,
6AA27078438185AEA8CDE2A1 /* TimelineControllerTests.swift in Sources */,
ECE4E03D17E309724013F825 /* TimelineInteractionHandlerTests.swift in Sources */,
0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */,
0668BECCF11E8EE6936CDF16 /* TimelineItemMenuActionProviderTests.swift in Sources */,
@@ -63,7 +63,8 @@ class TimelineController: TimelineControllerProtocol {
activeTimeline = timelineProxy
activeTimelineItemProvider = liveTimelineItemProvider
guard let initialFocussedEventID else {
// No need for a detached timeline when the live one has already loaded the event.
guard let initialFocussedEventID, !liveTimelineItemProvider.containsEvent(initialFocussedEventID) else {
configureActiveTimelineItemProvider()
return
}
@@ -765,3 +766,12 @@ private nonisolated extension TimelineItemProxy {
return false
}
}
private extension TimelineItemProviderProtocol {
func containsEvent(_ eventID: String) -> Bool {
itemProxies.contains { item in
guard case let .event(eventItem) = item else { return false }
return eventItem.id.eventID == eventID
}
}
}
@@ -0,0 +1,72 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
@testable import ElementX
import MatrixRustSDK
import Testing
@MainActor
struct TimelineControllerTests {
private let roomProxy = JoinedRoomProxyMock(.init())
@Test
func initialFocusOnLoadedEventUsesTheLiveTimeline() async throws {
let controller = makeController(liveEventIDs: ["$event"], initialFocussedEventID: "$event")
try await waitForTimelineItems(controller)
#expect(!roomProxy.timelineFocusedOnEventEventIDNumberOfEventsCalled)
#expect(controller.timelineKind == .live)
}
@Test
func initialFocusOnUnloadedEventLoadsADetachedTimeline() async throws {
let controller = makeController(liveEventIDs: ["$other"], initialFocussedEventID: "$event")
try await waitForTimelineItems(controller)
#expect(roomProxy.timelineFocusedOnEventEventIDNumberOfEventsReceivedArguments?.eventID == "$event")
}
// MARK: - Helpers
private func makeController(liveEventIDs: [String], initialFocussedEventID: String) -> TimelineController {
let itemProxies = liveEventIDs.enumerated().map { index, eventID in
TimelineItemProxy.event(EventTimelineItemProxy(item: .init(configuration: .init(eventID: eventID)), uniqueID: .init("\(index)")))
}
let paginationState = TimelinePaginationState(backward: .idle, forward: .endReached)
let provider = TimelineItemProviderMock()
provider.kind = .live
provider.itemProxies = itemProxies
provider.paginationState = paginationState
provider.membershipChangePublisher = PassthroughSubject().eraseToAnyPublisher()
provider.updatePublisher = CurrentValueSubject((itemProxies, paginationState)).eraseToAnyPublisher()
roomProxy.timelineFocusedOnEventEventIDNumberOfEventsReturnValue = .failure(.eventNotFound)
return TimelineController(roomProxy: roomProxy,
timelineProxy: TimelineProxyMock(.init(timelineItemProvider: provider)),
initialFocussedEventID: initialFocussedEventID,
timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org",
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")),
mediaProvider: MediaProviderMock(.init()),
userSettings: .volatile())
}
/// The live timeline's items are only published once any initial focus attempt has finished.
private func waitForTimelineItems(_ controller: TimelineController) async throws {
let deferred = deferFulfillment(controller.callbacks) { callback in
if case .updatedTimelineItems = callback {
true
} else {
false
}
}
try await deferred.fulfill()
}
}