diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b52798890..bb7b8f9ec 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -102,7 +102,6 @@ 1555A7643D85187D4851040C /* TemplateScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */; }; 157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */; }; 1583E2D766E4485FF91662FC /* PermalinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */; }; - 158A2D528CC78C4E7A8ED608 /* MockRoomTimelineControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71556206CD5E8B1F53F07178 /* MockRoomTimelineControllerFactory.swift */; }; 167D00CAA13FAFB822298021 /* MediaProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */; }; 16CBD087038DE3815CDA512C /* PollMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D38391154120264910D19528 /* PollMock.swift */; }; 1702981A8085BE4FB0EC001B /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33116993D54FADC0C721C1F /* Application.swift */; }; @@ -749,6 +748,7 @@ B22D857D1E8FCA6DD74A58E3 /* UserSessionScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */; }; B245583C63F8F90357B87FAE /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = A2AE110B053B55E38F8D10C7 /* KZFileWatchers */; }; B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */; }; + B31E5493C99381D4E204438B /* RoomTimelineControllerFactoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D479DF730528153665E5782E /* RoomTimelineControllerFactoryMock.swift */; }; B3D652AA1654270742072FB3 /* DeveloperOptionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */; }; B3EDDEC1839BB5A3747624BB /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A1CCDEE545CB6453B084BF /* FormButtonStyles.swift */; }; B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78910787F967CBC6042A101E /* StartChatScreenViewModelProtocol.swift */; }; @@ -1563,7 +1563,6 @@ 7061BE2C0BF427C38AEDEF5E /* SecureBackupRecoveryKeyScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupRecoveryKeyScreenViewModel.swift; sourceTree = ""; }; 70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreen.swift; sourceTree = ""; }; 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModelProtocol.swift; sourceTree = ""; }; - 71556206CD5E8B1F53F07178 /* MockRoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineControllerFactory.swift; sourceTree = ""; }; 718D8767035D37E2DB5CC550 /* QRCodeLoginScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenViewModelProtocol.swift; sourceTree = ""; }; 7199693797B66245EF97BCF5 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = ""; }; 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreen.swift; sourceTree = ""; }; @@ -1967,6 +1966,7 @@ D3F219838588C62198E726E3 /* LABiometryType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LABiometryType.swift; sourceTree = ""; }; D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreen.swift; sourceTree = ""; }; D45C9EAA86423D7D3126DE4F /* VoiceMessageRecorderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorderProtocol.swift; sourceTree = ""; }; + D479DF730528153665E5782E /* RoomTimelineControllerFactoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactoryMock.swift; sourceTree = ""; }; D49B9785E3AD7D1C15A29F2F /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRoomTimelineItem.swift; sourceTree = ""; }; @@ -2650,7 +2650,6 @@ isa = PBXGroup; children = ( 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */, - 71556206CD5E8B1F53F07178 /* MockRoomTimelineControllerFactory.swift */, 9F85164F9475FF2867F71AAA /* RoomTimelineController.swift */, E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */, 18FE0CDF1FFA92EA7EE17B0B /* RoomTimelineControllerFactoryProtocol.swift */, @@ -2681,6 +2680,7 @@ F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */, 1ABDE6F66532CBEB0E016F94 /* RoomProxyMock.swift */, FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */, + D479DF730528153665E5782E /* RoomTimelineControllerFactoryMock.swift */, 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */, 7893780A1FD6E3F38B3E9049 /* UserIndicatorControllerMock.swift */, AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */, @@ -6160,7 +6160,6 @@ 152AE2B8650FB23AFD2E28B9 /* MockAuthenticationServiceProxy.swift in Sources */, B659E3A49889E749E3239EA7 /* MockMediaProvider.swift in Sources */, 09C83DDDB07C28364F325209 /* MockRoomTimelineController.swift in Sources */, - 158A2D528CC78C4E7A8ED608 /* MockRoomTimelineControllerFactory.swift in Sources */, B721125D17A0BA86794F29FB /* MockServerSelectionScreenState.swift in Sources */, AF2ABA2794E376B64104C964 /* MockSoftLogoutScreenState.swift in Sources */, D8359F67AF3A83516E9083C1 /* MockUserSession.swift in Sources */, @@ -6367,6 +6366,7 @@ AA050DF4AEE54A641BA7CA22 /* RoomSummaryProviderProtocol.swift in Sources */, 2ABF11717C64054CEF2819A3 /* RoomTimelineController.swift in Sources */, 38896D54D6D675534E606195 /* RoomTimelineControllerFactory.swift in Sources */, + B31E5493C99381D4E204438B /* RoomTimelineControllerFactoryMock.swift in Sources */, 7ECF12D5DCD69F67BD3E3842 /* RoomTimelineControllerFactoryProtocol.swift in Sources */, E89536FC8C0E4B79E9842A78 /* RoomTimelineControllerProtocol.swift in Sources */, 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */, diff --git a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift index 0d882b813..3ba5cf885 100644 --- a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift +++ b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift @@ -379,9 +379,11 @@ private struct NavigationSplitCoordinatorView: View { // through the NavigationSplitCoordinator as well. .sheet(item: $navigationSplitCoordinator.sheetModule) { module in module.coordinator?.toPresentable() + .id(module.id) } .fullScreenCover(item: $navigationSplitCoordinator.fullScreenCoverModule) { module in module.coordinator?.toPresentable() + .id(module.id) } // Handle `horizontalSizeClass` changes breaking the navigation bar // https://github.com/element-hq/element-x-ios/issues/617 @@ -408,8 +410,10 @@ private struct NavigationSplitCoordinatorView: View { var navigationStack: some View { NavigationStack(path: navigationSplitCoordinator.compactLayoutStackModulesBinding) { navigationSplitCoordinator.compactLayoutRootModule?.coordinator?.toPresentable() + .id(navigationSplitCoordinator.compactLayoutRootModule?.id) // Is a nil ID ok? .navigationDestination(for: NavigationModule.self) { module in module.coordinator?.toPresentable() + .id(module.id) } } } @@ -419,19 +423,24 @@ private struct NavigationSplitCoordinatorView: View { NavigationSplitView(columnVisibility: $columnVisibility) { if let sidebarModule = navigationSplitCoordinator.sidebarModule { sidebarModule.coordinator?.toPresentable() + .id(sidebarModule.id) } else { navigationSplitCoordinator.placeholderModule.coordinator?.toPresentable() + .id(navigationSplitCoordinator.placeholderModule.id) } } detail: { if let detailModule = navigationSplitCoordinator.detailModule { detailModule.coordinator?.toPresentable() + .id(detailModule.id) } else { navigationSplitCoordinator.placeholderModule.coordinator?.toPresentable() + .id(navigationSplitCoordinator.placeholderModule.id) } } .navigationSplitViewStyle(.balanced) .navigationDestination(for: NavigationModule.self) { module in module.coordinator?.toPresentable() + .id(module.id) } .animation(.elementDefault, value: navigationSplitCoordinator.sidebarModule) .animation(.elementDefault, value: navigationSplitCoordinator.detailModule) @@ -713,15 +722,19 @@ private struct NavigationStackCoordinatorView: View { var body: some View { NavigationStack(path: $navigationStackCoordinator.stackModules) { navigationStackCoordinator.rootModule?.coordinator?.toPresentable() + .id(navigationStackCoordinator.rootModule?.id) // Is a nil ID ok? .navigationDestination(for: NavigationModule.self) { module in module.coordinator?.toPresentable() + .id(module.id) } } .sheet(item: $navigationStackCoordinator.sheetModule) { module in module.coordinator?.toPresentable() + .id(module.id) } .fullScreenCover(item: $navigationStackCoordinator.fullScreenCoverModule) { module in module.coordinator?.toPresentable() + .id(module.id) } .animation(.elementDefault, value: navigationStackCoordinator.rootModule) } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 0d825dc0b..40e254da1 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -47,6 +47,11 @@ enum RoomFlowCoordinatorEntryPoint: Hashable { case eventID(String) /// The flow will start by showing the room's details. case roomDetails + + var isEventID: Bool { + guard case .eventID = self else { return false } + return true + } } // swiftlint:disable:next type_body_length @@ -508,7 +513,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .initial, .roomDetails(isRoot: true), .joinRoomScreen: break default: - return // The room is already on the stack, no need to present it again + // The room is already on the stack, no need to present it again + + // Check if we need to focus on an event + if let focussedEventID { + roomScreenCoordinator?.focusOnEvent(eventID: focussedEventID) + } + + return } } @@ -524,6 +536,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, + initialFocussedEventID: focussedEventID, timelineItemFactory: timelineItemFactory) self.timelineController = timelineController @@ -1012,6 +1025,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) let roomTimelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, + initialFocussedEventID: nil, timelineItemFactory: timelineItemFactory) let parameters = RoomPollsHistoryScreenCoordinatorParameters(roomProxy: roomProxy, diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 0887ede49..6c352a4dc 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -272,15 +272,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentHomeScreen() attemptStartingOnboarding() case(.roomList(let selectedRoomID), .selectRoom(let roomID, let entryPoint), .roomList): - if selectedRoomID == roomID { - if let roomFlowCoordinator { - let route: AppRoute = switch entryPoint { - case .room: .room(roomID: roomID) - case .eventID(let eventID): .event(roomID: roomID, eventID: eventID) - case .roomDetails: .roomDetails(roomID: roomID) - } - roomFlowCoordinator.handleAppRoute(route, animated: animated) + if selectedRoomID == roomID, + !entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading. + let roomFlowCoordinator { + let route: AppRoute = switch entryPoint { + case .room: .room(roomID: roomID) + case .roomDetails: .roomDetails(roomID: roomID) + case .eventID(let eventID): .event(roomID: roomID, eventID: eventID) // ignored. } + roomFlowCoordinator.handleAppRoute(route, animated: animated) } else { Task { await self.startRoomFlow(roomID: roomID, entryPoint: entryPoint, animated: animated) } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 00e7513c3..bf8de25ee 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -9652,6 +9652,77 @@ class RoomSummaryProviderMock: RoomSummaryProviderProtocol { setFilterClosure?(filter) } } +class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol { + + //MARK: - buildRoomTimelineController + + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount = 0 + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount: Int { + get { + if Thread.isMainThread { + return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount = newValue + } + } + } + } + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCalled: Bool { + return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount > 0 + } + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments: (roomProxy: RoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol)? + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedInvocations: [(roomProxy: RoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol)] = [] + + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue: RoomTimelineControllerProtocol! + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue: RoomTimelineControllerProtocol! { + get { + if Thread.isMainThread { + return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue + } else { + var returnValue: RoomTimelineControllerProtocol? = nil + DispatchQueue.main.sync { + returnValue = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue = newValue + } + } + } + } + var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure: ((RoomProxyProtocol, String?, RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol)? + + func buildRoomTimelineController(roomProxy: RoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol { + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount += 1 + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments = (roomProxy: roomProxy, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory) + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedInvocations.append((roomProxy: roomProxy, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory)) + if let buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure { + return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure(roomProxy, initialFocussedEventID, timelineItemFactory) + } else { + return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue + } + } +} class RoomTimelineProviderMock: RoomTimelineProviderProtocol { var updatePublisher: AnyPublisher<([TimelineItemProxy], PaginationState), Never> { get { return underlyingUpdatePublisher } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift b/ElementX/Sources/Mocks/RoomTimelineControllerFactoryMock.swift similarity index 50% rename from ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift rename to ElementX/Sources/Mocks/RoomTimelineControllerFactoryMock.swift index b95c0f087..20badbb31 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Mocks/RoomTimelineControllerFactoryMock.swift @@ -16,11 +16,18 @@ import Foundation -struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { - func buildRoomTimelineController(roomProxy: RoomProxyProtocol, - timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol { - let timelineController = MockRoomTimelineController() - timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk - return timelineController +struct RoomTimelineControllerFactoryMockConfiguration { + var timelineController: MockRoomTimelineController? +} + +extension RoomTimelineControllerFactoryMock { + convenience init(configuration: RoomTimelineControllerFactoryMockConfiguration) { + self.init() + + buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue = configuration.timelineController ?? { + let timelineController = MockRoomTimelineController() + timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk + return timelineController + }() } } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift index 7ad303c57..49eea80d5 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift @@ -62,6 +62,7 @@ struct RoomDetailsScreen: View { } } .navigationTitle(L10n.screenRoomDetailsTitle) + .navigationBarTitleDisplayMode(.inline) .track(screen: .RoomDetails) .interactiveQuickLook(item: $context.mediaPreviewItem, shouldHideControls: true) } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index d91594811..b8273e7c2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -95,28 +95,28 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol bindings: .init(reactionsCollapsed: [:])), imageProvider: mediaProvider) - // This may change to load the detached timeline directly. - if let focussedEventID { - Task { await focusOnEvent(eventID: focussedEventID) } + if focussedEventID != nil { + // The timeline controller will start loading a detached timeline. + showFocusLoadingIndicator() } setupSubscriptions() setupDirectRoomSubscriptionsIfNeeded() - state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in + state.timelineItemMenuActionProvider = { [weak self] itemID -> TimelineItemMenuActions? in guard let self else { return nil } - return self.roomScreenInteractionHandler.timelineItemMenuActionsForItemId(itemId) + return self.roomScreenInteractionHandler.timelineItemMenuActionsForItemId(itemID) } - state.audioPlayerStateProvider = { [weak self] itemId -> AudioPlayerState? in + state.audioPlayerStateProvider = { [weak self] itemID -> AudioPlayerState? in guard let self else { return nil } - return self.roomScreenInteractionHandler.audioPlayerState(for: itemId) + return self.roomScreenInteractionHandler.audioPlayerState(for: itemID) } buildTimelineViews() @@ -191,8 +191,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol case .focusLive: focusLive() case .scrolledToFocussedItem: - // Use a Task to mutate view state after the current view update. - Task { state.timelineViewState.focussedEventNeedsDisplay = false } + Task { // Use a Task to mutate view state after the current view update. + state.timelineViewState.focussedEventNeedsDisplay = false + hideFocusLoadingIndicator() + } } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 5ba9e5986..8152ed449 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -44,6 +44,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } init(roomProxy: RoomProxyProtocol, + initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol, appSettings: AppSettings) { self.roomProxy = roomProxy @@ -54,9 +55,24 @@ class RoomTimelineController: RoomTimelineControllerProtocol { activeTimeline = roomProxy.timeline activeTimelineProvider = liveTimelineProvider - configureActiveTimelineProvider() NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) + + guard let initialFocussedEventID else { + configureActiveTimelineProvider() + return + } + + Task { + callbacks.send(.paginationState(PaginationState(backward: .paginating, forward: .paginating))) + switch await focusOnEvent(initialFocussedEventID, timelineSize: 100) { + case .success: + break + case .failure: + // Setup the live timeline as a fallback. + configureActiveTimelineProvider() + } + } } func focusOnEvent(_ eventID: String, timelineSize: UInt16) async -> Result { @@ -78,7 +94,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { func focusLive() { activeTimeline = roomProxy.timeline activeTimelineProvider = liveTimelineProvider - callbacks.send(.isLive(true)) } func paginateBackwards(requestSize: UInt16) async -> Result { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index 199eeaaa7..2dbfff198 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -18,8 +18,10 @@ import Foundation struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { func buildRoomTimelineController(roomProxy: RoomProxyProtocol, + initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol { RoomTimelineController(roomProxy: roomProxy, + initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory, appSettings: ServiceLocator.shared.settings) } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift index 7e8db09c1..3a5d9d551 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift @@ -19,5 +19,9 @@ import Foundation @MainActor protocol RoomTimelineControllerFactoryProtocol { func buildRoomTimelineController(roomProxy: RoomProxyProtocol, + initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol } + +// sourcery: AutoMockable +extension RoomTimelineControllerFactoryProtocol { } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index b6ae4cac2..dbce1a2a1 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -503,7 +503,7 @@ class MockScreen: Identifiable { appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings), bugReportService: BugReportServiceMock(), - roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), + roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init()), appMediator: AppMediatorMock.default, appSettings: appSettings, analytics: ServiceLocator.shared.analytics, diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 36075b248..02315d661 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -22,6 +22,7 @@ import Combine @MainActor class RoomFlowCoordinatorTests: XCTestCase { var clientProxy: ClientProxyMock! + var timelineControllerFactory: RoomTimelineControllerFactoryMock! var roomFlowCoordinator: RoomFlowCoordinator! var navigationStackCoordinator: NavigationStackCoordinator! var cancellables = Set() @@ -216,7 +217,7 @@ class RoomFlowCoordinatorTests: XCTestCase { XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) - try await process(route: .event(roomID: "1", eventID: "2")) + try await process(route: .childEvent(roomID: "1", eventID: "2")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) @@ -269,6 +270,7 @@ class RoomFlowCoordinatorTests: XCTestCase { private func setupRoomFlowCoordinator(asChildFlow: Bool = false, roomType: RoomType? = nil) async { cancellables.removeAll() clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) + timelineControllerFactory = RoomTimelineControllerFactoryMock(configuration: .init()) clientProxy.roomPreviewForIdentifierClosure = { [roomType] roomID in switch roomType { @@ -309,7 +311,7 @@ class RoomFlowCoordinatorTests: XCTestCase { roomFlowCoordinator = await RoomFlowCoordinator(roomID: roomID, userSession: userSession, isChildFlow: asChildFlow, - roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), + roomTimelineControllerFactory: timelineControllerFactory, navigationStackCoordinator: navigationStackCoordinator, emojiProvider: EmojiProvider(), appMediator: AppMediatorMock.default, diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index ef6681dc0..b69ea06b4 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -172,6 +172,7 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(timelineController.focusOnEventCallCount, 0) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEventID) + XCTAssertFalse(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) // When focussing on an item that isn't loaded. let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineViewState.isLive } @@ -182,6 +183,7 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(timelineController.focusOnEventCallCount, 1) XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive) XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t4") + XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) } func testFocusLoadedItem() async throws { @@ -196,6 +198,7 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(timelineController.focusOnEventCallCount, 0) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEventID) + XCTAssertFalse(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) // When focussing on a loaded item. let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineViewState.isLive } @@ -206,6 +209,7 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(timelineController.focusOnEventCallCount, 0) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t1") + XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) } func testFocusLive() async throws { @@ -225,6 +229,7 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(timelineController.focusLiveCallCount, 0) XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive) XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t4") + XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) // When switching back to a live timeline. deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineViewState.isLive } @@ -235,6 +240,15 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(timelineController.focusLiveCallCount, 1) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEventID) + XCTAssertFalse(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) + } + + func testInitialFocusViewState() async throws { + let timelineController = MockRoomTimelineController() + + let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController) + XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t10") + XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay) } // MARK: - Sending @@ -464,8 +478,11 @@ class RoomScreenViewModelTests: XCTestCase { // MARK: - Helpers - private func makeViewModel(roomProxy: RoomProxyProtocol? = nil, timelineController: RoomTimelineControllerProtocol) -> RoomScreenViewModel { + private func makeViewModel(roomProxy: RoomProxyProtocol? = nil, + focussedEventID: String? = nil, + timelineController: RoomTimelineControllerProtocol) -> RoomScreenViewModel { RoomScreenViewModel(roomProxy: roomProxy ?? RoomProxyMock(with: .init(name: "")), + focussedEventID: focussedEventID, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index 90732db97..5e788b249 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -22,6 +22,7 @@ import Combine @MainActor class UserSessionFlowCoordinatorTests: XCTestCase { var clientProxy: ClientProxyMock! + var timelineControllerFactory: RoomTimelineControllerFactoryMock! var userSessionFlowCoordinator: UserSessionFlowCoordinator! var navigationRootCoordinator: NavigationRootCoordinator! var notificationManager: NotificationManagerMock! @@ -35,6 +36,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase { override func setUp() async throws { cancellables.removeAll() clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) + timelineControllerFactory = RoomTimelineControllerFactoryMock(configuration: .init()) let mediaProvider = MockMediaProvider() let voiceMessageMediaManager = VoiceMessageMediaManagerMock() let userSession = MockUserSession(clientProxy: clientProxy, @@ -49,7 +51,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase { navigationRootCoordinator: navigationRootCoordinator, appLockService: AppLockServiceMock(), bugReportService: BugReportServiceMock(), - roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), + roomTimelineControllerFactory: timelineControllerFactory, appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -216,22 +218,40 @@ class UserSessionFlowCoordinatorTests: XCTestCase { } func testEventRoutes() async throws { + // A regular event route should set its room as the root of the stack and focus on the event. try await process(route: .event(roomID: "1", eventID: "1"), expectedState: .roomList(selectedRoomID: "1")) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertNotNil(detailCoordinator) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount, 1) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments?.initialFocussedEventID, "1") + // A child event route should push a new room screen onto the stack and focus on the event. userSessionFlowCoordinator.handleAppRoute(.childEvent(roomID: "2", eventID: "2"), animated: true) try await Task.sleep(for: .milliseconds(100)) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1) XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator) XCTAssertNotNil(detailCoordinator) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount, 2) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments?.initialFocussedEventID, "2") + // A subsequent regular event route should clear the stack and set the new room as the root of the stack. try await process(route: .event(roomID: "3", eventID: "3"), expectedState: .roomList(selectedRoomID: "3")) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertNotNil(detailCoordinator) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount, 3) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments?.initialFocussedEventID, "3") + + // A regular event route for the same room should set a new instance of the room as the root of the stack. + try await process(route: .event(roomID: "3", eventID: "4"), expectedState: .roomList(selectedRoomID: "3")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) + XCTAssertNotNil(detailCoordinator) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount, 4) + XCTAssertEqual(timelineControllerFactory.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments?.initialFocussedEventID, "4", + "A new timeline should be created for the same room ID, so that the screen isn't stale while loading.") } // MARK: - Private