implemented navigation for permalinks to threads

also this handles the case where we are permalinking to non existent (or impossible to fetch) event.
This commit is contained in:
Mauro Romito
2025-10-01 13:18:53 +02:00
committed by Mauro
parent cf5a72bc10
commit 0288ce6b4b
6 changed files with 100 additions and 47 deletions
@@ -68,6 +68,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private var roomProxy: JoinedRoomProxyProtocol! private var roomProxy: JoinedRoomProxyProtocol!
private var roomScreenCoordinator: RoomScreenCoordinator? private var roomScreenCoordinator: RoomScreenCoordinator?
private var childThreads: [ThreadTimelineScreenCoordinator] = []
private weak var joinRoomScreenCoordinator: JoinRoomScreenCoordinator? private weak var joinRoomScreenCoordinator: JoinRoomScreenCoordinator?
// periphery:ignore - used to avoid deallocation // periphery:ignore - used to avoid deallocation
@@ -163,7 +164,28 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else if roomID != roomProxy.id { } else if roomID != roomProxy.id {
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated))
} else { } 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): case .share(let payload):
guard let roomID = payload.roomID, roomID == self.roomID else { guard let roomID = payload.roomID, roomID == self.roomID else {
@@ -232,12 +254,27 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch room { switch room {
case .joined(let roomProxy): case .joined(let roomProxy):
await storeAndSubscribeToRoomProxy(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)) stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
default: default:
stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated))
} }
} }
func clearRoute(animated: Bool) { func clearRoute(animated: Bool) {
guard stateMachine.state != .initial else { guard stateMachine.state != .initial else {
return return
@@ -305,8 +342,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// Thread // Thread
case (.room, .presentThread(let itemID), .thread): case (_, .presentThread(let threadRootEventID, let focusEventID), .thread):
Task { await self.presentThread(itemID: itemID, animated: animated) } Task { await self.presentThread(threadRootEventID: threadRootEventID, focusEventID: focusEventID, animated: animated) }
// Thread + Room // Thread + Room
@@ -605,7 +642,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentKnockRequestsList: case .presentKnockRequestsList:
stateMachine.tryEvent(.presentKnockRequestsListScreen) stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentThread(let itemID): 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): case .presentRoom(let roomID, let via):
stateMachine.tryEvent(.startChildFlow(roomID: roomID, stateMachine.tryEvent(.startChildFlow(roomID: roomID,
via: via, via: via,
@@ -617,7 +657,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return coordinator return coordinator
} }
private func presentThread(itemID: TimelineItemIdentifier, animated: Bool) async { private func presentThread(threadRootEventID: String, focusEventID: String?, animated: Bool) async {
showLoadingIndicator() showLoadingIndicator()
defer { hideLoadingIndicator() } defer { hideLoadingIndicator() }
@@ -625,10 +665,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) 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, guard case let .success(timelineController) = await flowParameters.timelineControllerFactory.buildThreadTimelineController(eventID: threadRootEventID,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory, timelineItemFactory: timelineItemFactory,
@@ -645,6 +681,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(userSession: userSession, let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(userSession: userSession,
roomProxy: roomProxy, roomProxy: roomProxy,
focussedEventID: focusEventID,
timelineController: timelineController, timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProvider(), mediaPlayerProvider: MediaPlayerProvider(),
emojiProvider: flowParameters.emojiProvider, emojiProvider: flowParameters.emojiProvider,
@@ -693,9 +730,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
.store(in: &cancellables) .store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissThread) guard let self else { return }
stateMachine.tryEvent(.dismissThread)
childThreads.removeAll { $0 === coordinator }
} }
childThreads.append(coordinator)
} }
private func presentJoinRoomScreen(via: [String], animated: Bool) { private func presentJoinRoomScreen(via: [String], animated: Bool) {
@@ -1579,4 +1620,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private func hideLoadingIndicator() { private func hideLoadingIndicator() {
flowParameters.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorID) flowParameters.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorID)
} }
private func showError() {
flowParameters.userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
} }
@@ -48,7 +48,7 @@ extension RoomFlowCoordinator {
case initial case initial
case joinRoomScreen case joinRoomScreen
case room case room
case thread(itemID: TimelineItemIdentifier) case thread(threadRootEventID: String, previousState: State)
case roomDetails(isRoot: Bool) case roomDetails(isRoot: Bool)
case roomDetailsEditScreen case roomDetailsEditScreen
case notificationSettings case notificationSettings
@@ -95,7 +95,7 @@ extension RoomFlowCoordinator {
case presentRoom(presentationAction: PresentationAction?) case presentRoom(presentationAction: PresentationAction?)
case dismissFlow case dismissFlow
case presentThread(itemID: TimelineItemIdentifier) case presentThread(threadRootEventID: String, focusEventID: String?)
case dismissThread case dismissThread
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
@@ -216,10 +216,12 @@ extension RoomFlowCoordinator {
return previousState return previousState
// Thread // Thread
case (.room, .presentThread(let itemID)): case (.room, .presentThread(let threadRootEventID, _)):
return .thread(itemID: itemID) return .thread(threadRootEventID: threadRootEventID, previousState: fromState)
case (.thread, .dismissThread): case (.thread, .presentThread(let threadRootEventID, _)):
return .room return .thread(threadRootEventID: threadRootEventID, previousState: fromState)
case (.thread(_, let previousState), .dismissThread):
return previousState
case (.thread, .presentReportContent(let itemID, let senderID)): case (.thread, .presentReportContent(let itemID, let senderID)):
return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState) return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState)
@@ -6483,17 +6483,17 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
return threadTimelineEventIDReturnValue return threadTimelineEventIDReturnValue
} }
} }
//MARK: - getThreadRootEventID //MARK: - loadOrFetchEvent
var getThreadRootEventIDForUnderlyingCallsCount = 0 var loadOrFetchEventForUnderlyingCallsCount = 0
var getThreadRootEventIDForCallsCount: Int { var loadOrFetchEventForCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return getThreadRootEventIDForUnderlyingCallsCount return loadOrFetchEventForUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = getThreadRootEventIDForUnderlyingCallsCount returnValue = loadOrFetchEventForUnderlyingCallsCount
} }
return returnValue! return returnValue!
@@ -6501,29 +6501,29 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
getThreadRootEventIDForUnderlyingCallsCount = newValue loadOrFetchEventForUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
getThreadRootEventIDForUnderlyingCallsCount = newValue loadOrFetchEventForUnderlyingCallsCount = newValue
} }
} }
} }
} }
var getThreadRootEventIDForCalled: Bool { var loadOrFetchEventForCalled: Bool {
return getThreadRootEventIDForCallsCount > 0 return loadOrFetchEventForCallsCount > 0
} }
var getThreadRootEventIDForReceivedEventID: String? var loadOrFetchEventForReceivedEventID: String?
var getThreadRootEventIDForReceivedInvocations: [String] = [] var loadOrFetchEventForReceivedInvocations: [String] = []
var getThreadRootEventIDForUnderlyingReturnValue: Result<String?, RoomProxyError>! var loadOrFetchEventForUnderlyingReturnValue: Result<TimelineEvent, RoomProxyError>!
var getThreadRootEventIDForReturnValue: Result<String?, RoomProxyError>! { var loadOrFetchEventForReturnValue: Result<TimelineEvent, RoomProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return getThreadRootEventIDForUnderlyingReturnValue return loadOrFetchEventForUnderlyingReturnValue
} else { } else {
var returnValue: Result<String?, RoomProxyError>? = nil var returnValue: Result<TimelineEvent, RoomProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = getThreadRootEventIDForUnderlyingReturnValue returnValue = loadOrFetchEventForUnderlyingReturnValue
} }
return returnValue! return returnValue!
@@ -6531,26 +6531,26 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
getThreadRootEventIDForUnderlyingReturnValue = newValue loadOrFetchEventForUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
getThreadRootEventIDForUnderlyingReturnValue = newValue loadOrFetchEventForUnderlyingReturnValue = newValue
} }
} }
} }
} }
var getThreadRootEventIDForClosure: ((String) async -> Result<String?, RoomProxyError>)? var loadOrFetchEventForClosure: ((String) async -> Result<TimelineEvent, RoomProxyError>)?
func getThreadRootEventID(for eventID: String) async -> Result<String?, RoomProxyError> { func loadOrFetchEvent(for eventID: String) async -> Result<TimelineEvent, RoomProxyError> {
getThreadRootEventIDForCallsCount += 1 loadOrFetchEventForCallsCount += 1
getThreadRootEventIDForReceivedEventID = eventID loadOrFetchEventForReceivedEventID = eventID
DispatchQueue.main.async { DispatchQueue.main.async {
self.getThreadRootEventIDForReceivedInvocations.append(eventID) self.loadOrFetchEventForReceivedInvocations.append(eventID)
} }
if let getThreadRootEventIDForClosure = getThreadRootEventIDForClosure { if let loadOrFetchEventForClosure = loadOrFetchEventForClosure {
return await getThreadRootEventIDForClosure(eventID) return await loadOrFetchEventForClosure(eventID)
} else { } else {
return getThreadRootEventIDForReturnValue return loadOrFetchEventForReturnValue
} }
} }
//MARK: - messageFilteredTimeline //MARK: - messageFilteredTimeline
@@ -13,6 +13,7 @@ import WysiwygComposer
struct ThreadTimelineScreenCoordinatorParameters { struct ThreadTimelineScreenCoordinatorParameters {
let userSession: UserSessionProtocol let userSession: UserSessionProtocol
let roomProxy: JoinedRoomProxyProtocol let roomProxy: JoinedRoomProxyProtocol
let focussedEventID: String?
let timelineController: TimelineControllerProtocol let timelineController: TimelineControllerProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol
let emojiProvider: EmojiProviderProtocol let emojiProvider: EmojiProviderProtocol
@@ -58,6 +59,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy, userSession: parameters.userSession) viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy, userSession: parameters.userSession)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
userSession: parameters.userSession, userSession: parameters.userSession,
mediaPlayerProvider: parameters.mediaPlayerProvider, mediaPlayerProvider: parameters.mediaPlayerProvider,
@@ -155,4 +157,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
timelineContext: timelineViewModel.context, timelineContext: timelineViewModel.context,
composerToolbar: composerToolbar)) composerToolbar: composerToolbar))
} }
func focusOnEvent(eventID: String) {
Task { await timelineViewModel.focusOnEvent(eventID: eventID) }
}
} }
@@ -190,10 +190,10 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
} }
} }
func getThreadRootEventID(for eventID: String) async -> Result<String?, RoomProxyError> { func loadOrFetchEvent(for eventID: String) async -> Result<TimelineEvent, RoomProxyError> {
do { do {
let event = try await room.loadOrFetchEvent(eventId: eventID) let event = try await room.loadOrFetchEvent(eventId: eventID)
return .success(event.threadRootEventId()) return .success(event)
} catch { } catch {
MXLog.error("Failed fetching the event with id: \(eventID) with error: \(error)") MXLog.error("Failed fetching the event with id: \(eventID) with error: \(error)")
return .failure(.sdkError(error)) return .failure(.sdkError(error))
@@ -97,7 +97,7 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
func threadTimeline(eventID: String) async -> Result<TimelineProxyProtocol, RoomProxyError> func threadTimeline(eventID: String) async -> Result<TimelineProxyProtocol, RoomProxyError>
func getThreadRootEventID(for eventID: String) async -> Result<String?, RoomProxyError> func loadOrFetchEvent(for eventID: String) async -> Result<TimelineEvent, RoomProxyError>
func messageFilteredTimeline(focus: TimelineFocus, func messageFilteredTimeline(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType], allowedMessageTypes: [TimelineAllowedMessageType],