diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f64dedaaa..68b2b31e4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; FDEDD4D2DE0646DA724985D5 /* QRCodeLoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenModels.swift; sourceTree = ""; }; FDF73F49E6B6683F7E2D26F0 /* SecureBackupScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreenCoordinator.swift; sourceTree = ""; }; + FE07EC9D34EF91795EDB3CC4 /* TimelineControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineControllerTests.swift; sourceTree = ""; }; FE1E6FAA3719E9B7A2D5510B /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; FE3AB4D399D39FBD78119011 /* AuthenticationStartScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreenModels.swift; sourceTree = ""; }; FE5CD2993048222B64C45006 /* SDKListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKListener.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index ff304426f..5c6c95272 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -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 + } + } +} diff --git a/UnitTests/Sources/TimelineControllerTests.swift b/UnitTests/Sources/TimelineControllerTests.swift new file mode 100644 index 000000000..c52bf1d09 --- /dev/null +++ b/UnitTests/Sources/TimelineControllerTests.swift @@ -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() + } +}