From 0288ce6b4bafd9b7985d32feb22c8c03d564c931 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Wed, 1 Oct 2025 13:18:53 +0200 Subject: [PATCH] implemented navigation for permalinks to threads also this handles the case where we are permalinking to non existent (or impossible to fetch) event. --- .../RoomFlowCoordinator.swift | 69 +++++++++++++++---- .../RoomFlowCoordinatorStateMachine.swift | 14 ++-- .../Mocks/Generated/GeneratedMocks.swift | 52 +++++++------- .../ThreadTimelineScreenCoordinator.swift | 6 ++ .../Services/Room/JoinedRoomProxy.swift | 4 +- .../Services/Room/RoomProxyProtocol.swift | 2 +- 6 files changed, 100 insertions(+), 47 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 8ad72c972..e831c9523 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -68,6 +68,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private var roomProxy: JoinedRoomProxyProtocol! private var roomScreenCoordinator: RoomScreenCoordinator? + private var childThreads: [ThreadTimelineScreenCoordinator] = [] private weak var joinRoomScreenCoordinator: JoinRoomScreenCoordinator? // periphery:ignore - used to avoid deallocation @@ -163,7 +164,28 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } else if roomID != roomProxy.id { stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) } else { - roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) + showLoadingIndicator(delay: .seconds(0.5)) + Task { + defer { hideLoadingIndicator() } + switch await roomProxy.loadOrFetchEvent(for: eventID) { + case .success(let event): + if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { + if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreads.last { + threadCoordinator.focusOnEvent(eventID: eventID) + } else { + stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: eventID)) + } + } else if !childThreads.isEmpty { + // If we are showing a child thread, and we are navigating to a non threaded event + // of the same room, we want to push the room on top of the thread + stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) + } else { + roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) + } + case .failure: + showError() + } + } } case .share(let payload): guard let roomID = payload.roomID, roomID == self.roomID else { @@ -232,12 +254,27 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch room { case .joined(let roomProxy): await storeAndSubscribeToRoomProxy(roomProxy) + if case let .eventFocus(focusEvent) = presentationAction { + switch await roomProxy.loadOrFetchEvent(for: focusEvent.eventID) { + case .success(let event): + guard flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() else { + break + } + stateMachine.tryEvent(.presentRoom(presentationAction: nil), userInfo: EventUserInfo(animated: animated)) + stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focusEvent.eventID), userInfo: EventUserInfo(animated: false)) + return + case .failure: + showError() + stateMachine.tryEvent(.presentRoom(presentationAction: nil), userInfo: EventUserInfo(animated: animated)) + return + } + } stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated)) default: stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated)) } } - + func clearRoute(animated: Bool) { guard stateMachine.state != .initial else { return @@ -305,8 +342,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // Thread - case (.room, .presentThread(let itemID), .thread): - Task { await self.presentThread(itemID: itemID, animated: animated) } + case (_, .presentThread(let threadRootEventID, let focusEventID), .thread): + Task { await self.presentThread(threadRootEventID: threadRootEventID, focusEventID: focusEventID, animated: animated) } // Thread + Room @@ -605,7 +642,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .presentKnockRequestsList: stateMachine.tryEvent(.presentKnockRequestsListScreen) case .presentThread(let itemID): - stateMachine.tryEvent(.presentThread(itemID: itemID)) + guard let threadRootEventID = itemID.eventID else { + fatalError("A thread root has always an eventID") + } + stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: nil)) case .presentRoom(let roomID, let via): stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, @@ -617,7 +657,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return coordinator } - private func presentThread(itemID: TimelineItemIdentifier, animated: Bool) async { + private func presentThread(threadRootEventID: String, focusEventID: String?, animated: Bool) async { showLoadingIndicator() defer { hideLoadingIndicator() } @@ -625,10 +665,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) - guard let threadRootEventID = itemID.eventID else { - fatalError("Invalid thread event ID") - } - guard case let .success(timelineController) = await flowParameters.timelineControllerFactory.buildThreadTimelineController(eventID: threadRootEventID, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, @@ -645,6 +681,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(userSession: userSession, roomProxy: roomProxy, + focussedEventID: focusEventID, timelineController: timelineController, mediaPlayerProvider: MediaPlayerProvider(), emojiProvider: flowParameters.emojiProvider, @@ -693,9 +730,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - navigationStackCoordinator.push(coordinator) { [weak self] in - self?.stateMachine.tryEvent(.dismissThread) + navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in + guard let self else { return } + stateMachine.tryEvent(.dismissThread) + childThreads.removeAll { $0 === coordinator } } + + childThreads.append(coordinator) } private func presentJoinRoomScreen(via: [String], animated: Bool) { @@ -1579,4 +1620,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private func hideLoadingIndicator() { flowParameters.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorID) } + + private func showError() { + flowParameters.userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + } } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift index d101b5432..ccb18db0e 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift @@ -48,7 +48,7 @@ extension RoomFlowCoordinator { case initial case joinRoomScreen case room - case thread(itemID: TimelineItemIdentifier) + case thread(threadRootEventID: String, previousState: State) case roomDetails(isRoot: Bool) case roomDetailsEditScreen case notificationSettings @@ -95,7 +95,7 @@ extension RoomFlowCoordinator { case presentRoom(presentationAction: PresentationAction?) case dismissFlow - case presentThread(itemID: TimelineItemIdentifier) + case presentThread(threadRootEventID: String, focusEventID: String?) case dismissThread case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) @@ -216,10 +216,12 @@ extension RoomFlowCoordinator { return previousState // Thread - case (.room, .presentThread(let itemID)): - return .thread(itemID: itemID) - case (.thread, .dismissThread): - return .room + case (.room, .presentThread(let threadRootEventID, _)): + return .thread(threadRootEventID: threadRootEventID, previousState: fromState) + case (.thread, .presentThread(let threadRootEventID, _)): + return .thread(threadRootEventID: threadRootEventID, previousState: fromState) + case (.thread(_, let previousState), .dismissThread): + return previousState case (.thread, .presentReportContent(let itemID, let senderID)): return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index ca0457588..a11a7230d 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -6483,17 +6483,17 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { return threadTimelineEventIDReturnValue } } - //MARK: - getThreadRootEventID + //MARK: - loadOrFetchEvent - var getThreadRootEventIDForUnderlyingCallsCount = 0 - var getThreadRootEventIDForCallsCount: Int { + var loadOrFetchEventForUnderlyingCallsCount = 0 + var loadOrFetchEventForCallsCount: Int { get { if Thread.isMainThread { - return getThreadRootEventIDForUnderlyingCallsCount + return loadOrFetchEventForUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = getThreadRootEventIDForUnderlyingCallsCount + returnValue = loadOrFetchEventForUnderlyingCallsCount } return returnValue! @@ -6501,29 +6501,29 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } set { if Thread.isMainThread { - getThreadRootEventIDForUnderlyingCallsCount = newValue + loadOrFetchEventForUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - getThreadRootEventIDForUnderlyingCallsCount = newValue + loadOrFetchEventForUnderlyingCallsCount = newValue } } } } - var getThreadRootEventIDForCalled: Bool { - return getThreadRootEventIDForCallsCount > 0 + var loadOrFetchEventForCalled: Bool { + return loadOrFetchEventForCallsCount > 0 } - var getThreadRootEventIDForReceivedEventID: String? - var getThreadRootEventIDForReceivedInvocations: [String] = [] + var loadOrFetchEventForReceivedEventID: String? + var loadOrFetchEventForReceivedInvocations: [String] = [] - var getThreadRootEventIDForUnderlyingReturnValue: Result! - var getThreadRootEventIDForReturnValue: Result! { + var loadOrFetchEventForUnderlyingReturnValue: Result! + var loadOrFetchEventForReturnValue: Result! { get { if Thread.isMainThread { - return getThreadRootEventIDForUnderlyingReturnValue + return loadOrFetchEventForUnderlyingReturnValue } else { - var returnValue: Result? = nil + var returnValue: Result? = nil DispatchQueue.main.sync { - returnValue = getThreadRootEventIDForUnderlyingReturnValue + returnValue = loadOrFetchEventForUnderlyingReturnValue } return returnValue! @@ -6531,26 +6531,26 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } set { if Thread.isMainThread { - getThreadRootEventIDForUnderlyingReturnValue = newValue + loadOrFetchEventForUnderlyingReturnValue = newValue } else { DispatchQueue.main.sync { - getThreadRootEventIDForUnderlyingReturnValue = newValue + loadOrFetchEventForUnderlyingReturnValue = newValue } } } } - var getThreadRootEventIDForClosure: ((String) async -> Result)? + var loadOrFetchEventForClosure: ((String) async -> Result)? - func getThreadRootEventID(for eventID: String) async -> Result { - getThreadRootEventIDForCallsCount += 1 - getThreadRootEventIDForReceivedEventID = eventID + func loadOrFetchEvent(for eventID: String) async -> Result { + loadOrFetchEventForCallsCount += 1 + loadOrFetchEventForReceivedEventID = eventID DispatchQueue.main.async { - self.getThreadRootEventIDForReceivedInvocations.append(eventID) + self.loadOrFetchEventForReceivedInvocations.append(eventID) } - if let getThreadRootEventIDForClosure = getThreadRootEventIDForClosure { - return await getThreadRootEventIDForClosure(eventID) + if let loadOrFetchEventForClosure = loadOrFetchEventForClosure { + return await loadOrFetchEventForClosure(eventID) } else { - return getThreadRootEventIDForReturnValue + return loadOrFetchEventForReturnValue } } //MARK: - messageFilteredTimeline diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index f8b8c960c..d45535531 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -13,6 +13,7 @@ import WysiwygComposer struct ThreadTimelineScreenCoordinatorParameters { let userSession: UserSessionProtocol let roomProxy: JoinedRoomProxyProtocol + let focussedEventID: String? let timelineController: TimelineControllerProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol let emojiProvider: EmojiProviderProtocol @@ -58,6 +59,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy, userSession: parameters.userSession) timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, + focussedEventID: parameters.focussedEventID, timelineController: parameters.timelineController, userSession: parameters.userSession, mediaPlayerProvider: parameters.mediaPlayerProvider, @@ -155,4 +157,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { timelineContext: timelineViewModel.context, composerToolbar: composerToolbar)) } + + func focusOnEvent(eventID: String) { + Task { await timelineViewModel.focusOnEvent(eventID: eventID) } + } } diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 65811b36f..cef6b078a 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -190,10 +190,10 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } - func getThreadRootEventID(for eventID: String) async -> Result { + func loadOrFetchEvent(for eventID: String) async -> Result { do { let event = try await room.loadOrFetchEvent(eventId: eventID) - return .success(event.threadRootEventId()) + return .success(event) } catch { MXLog.error("Failed fetching the event with id: \(eventID) with error: \(error)") return .failure(.sdkError(error)) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 7cb765410..5725a3ac6 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -97,7 +97,7 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func threadTimeline(eventID: String) async -> Result - func getThreadRootEventID(for eventID: String) async -> Result + func loadOrFetchEvent(for eventID: String) async -> Result func messageFilteredTimeline(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType],