From 2ccbe52bc69d17288b0bb4a91bd0330f1a93cb97 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:36:42 +0100 Subject: [PATCH] Handle threaded pinned events (#4704) * implemented focussing a threaded event through the banner tap * view in timeline implementation for threaded events * added a test and improved the existing one * pr suggestions * put the logic for fetching the event in the view models --- .../PinnedEventsTimelineFlowCoordinator.swift | 6 +- .../RoomFlowCoordinator.swift | 43 ++++++++----- .../RoomFlowCoordinatorStateMachine.swift | 8 +-- ...innedEventsTimelineScreenCoordinator.swift | 19 +++--- .../PinnedEventsTimelineScreenModels.swift | 2 +- .../PinnedEventsTimelineScreenViewModel.swift | 32 +++++++++- .../View/PinnedEventsTimelineScreen.swift | 6 +- .../RoomScreen/RoomScreenCoordinator.swift | 14 ++++- .../Screens/RoomScreen/RoomScreenModels.swift | 1 + .../RoomScreen/RoomScreenViewModel.swift | 27 ++++++-- .../Screens/Timeline/TimelineModels.swift | 2 +- .../Screens/Timeline/TimelineViewModel.swift | 16 ++++- .../Sources/RoomScreenViewModelTests.swift | 63 +++++++++++++++++++ 13 files changed, 194 insertions(+), 45 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index 358e8efe2..e45c014f4 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -13,7 +13,7 @@ enum PinnedEventsTimelineFlowCoordinatorAction { case finished case displayUser(userID: String) case forwardedMessageToRoom(roomID: String) - case displayRoomScreenWithFocussedPin(eventID: String) + case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?) } class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { @@ -87,8 +87,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController) case .displayMessageForwarding(let forwardingItem): presentMessageForwarding(with: forwardingItem) - case .displayRoomScreenWithFocussedPin(let eventID): - actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) + case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID): + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 789f34292..1ed593712 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -165,10 +165,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } case .thread(let roomID, let threadRootEventID, let focusEventID): Task { + let focusEvent: FocusEvent? = if let focusEventID { + .init(eventID: focusEventID, shouldSetPin: false) + } else { + nil + } await handleRoomRoute(roomID: roomID, via: [], presentationAction: .thread(rootEventID: threadRootEventID, - focusEventID: focusEventID), + focusEvent: focusEvent), animated: animated) } case .event(let eventID, let roomID, let via): @@ -304,7 +309,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) { case .success(let event): if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { - stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEventID: focusEvent.eventID)), userInfo: EventUserInfo(animated: animated)) + stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, + focusEvent: .init(eventID: focusEvent.eventID, + shouldSetPin: focusEvent.shouldSetPin))), + userInfo: EventUserInfo(animated: animated)) } else { stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated)) } @@ -570,9 +578,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) case .share(.text(_, let text)): roomScreenCoordinator?.shareText(text) - case .thread(let rootEventID, let focusEventID): - roomScreenCoordinator?.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false)) - stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID)) + case .thread(let rootEventID, let focusEvent): + if let focusEvent, let roomScreenCoordinator { + roomScreenCoordinator.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false)) + // Since the root is not the message we want to show in the pinned banner, but the actual event + if focusEvent.shouldSetPin { + roomScreenCoordinator.setSelectedPin(eventID: focusEvent.eventID) + } + } + stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEvent?.eventID)) case .none: break } @@ -607,8 +621,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .share(.mediaFiles(_, let mediaFiles)): stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)), userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) - case .thread(let rootEventID, let focusEventID): - stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID)) + case .thread(let rootEventID, let focusEvent): + stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEvent?.eventID)) case .share(.text), .eventFocus: break // These are both handled in the coordinator's init. case .none: @@ -693,11 +707,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { sendHandle: sendHandle)) case .presentKnockRequestsList: stateMachine.tryEvent(.presentKnockRequestsListScreen) - case .presentThread(let itemID): - guard let threadRootEventID = itemID.eventID else { - fatalError("A thread root has always an eventID") - } - stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: nil)) + case .presentThread(let threadRootEventID, let focussedEventID): + stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focussedEventID)) case .presentRoom(let roomID, let via): stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, @@ -1478,9 +1489,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .forwardedMessageToRoom(let roomID): navigationStackCoordinator.setSheetCoordinator(nil) stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) - case .displayRoomScreenWithFocussedPin(let eventID): + case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID): navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true)))) + if let threadRootEventID { + stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEvent: .init(eventID: eventID, shouldSetPin: true)))) + } else { + stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true)))) + } } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift index 0a1415740..e14322f47 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift @@ -25,18 +25,18 @@ extension RoomFlowCoordinator { enum PresentationAction: Hashable { case eventFocus(FocusEvent) case share(ShareExtensionPayload) - case thread(rootEventID: String, focusEventID: String?) + case thread(rootEventID: String, focusEvent: FocusEvent?) var focusedEvent: FocusEvent? { switch self { case .eventFocus(let focusEvent): focusEvent - case .thread(let rootEventID, let focusEventID): + case .thread(let rootEventID, let focusEvent): // Since this enum is for the room and not the threaded timeline, // we will focus the thread root event id, and not the event id itself // which will be done at the thread presentation level - if focusEventID != nil { - .init(eventID: rootEventID, shouldSetPin: false) + if let focusEvent { + .init(eventID: rootEventID, shouldSetPin: focusEvent.shouldSetPin) } else { nil } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift index 02380177f..e33c19a78 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift @@ -28,11 +28,10 @@ enum PinnedEventsTimelineScreenCoordinatorAction { case displayUser(userID: String) case presentLocationViewer(geoURI: GeoURI, description: String?) case displayMessageForwarding(forwardingItem: MessageForwardingItem) - case displayRoomScreenWithFocussedPin(eventID: String) + case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?) } final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { - private let parameters: PinnedEventsTimelineScreenCoordinatorParameters private let viewModel: PinnedEventsTimelineScreenViewModelProtocol private let timelineViewModel: TimelineViewModelProtocol @@ -44,9 +43,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { } init(parameters: PinnedEventsTimelineScreenCoordinatorParameters) { - self.parameters = parameters - - viewModel = PinnedEventsTimelineScreenViewModel(analyticsService: parameters.analytics) + viewModel = PinnedEventsTimelineScreenViewModel(roomProxy: parameters.roomProxy, + userIndicatorController: parameters.userIndicatorController, + appSettings: parameters.appSettings, + analyticsService: parameters.analytics) timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, timelineController: parameters.timelineController, userSession: parameters.userSession, @@ -68,9 +68,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { switch action { case .displayMessageForwarding(let forwardingItem): actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) - case .viewInRoomTimeline(let itemID): - guard let eventID = itemID.eventID else { fatalError("A pinned event must have an event ID.") } - actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) + case .viewInRoomTimeline(let eventID, let threadRootEventID): + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID)) case .dismiss: self.actionsSubject.send(.dismiss) } @@ -90,8 +89,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { viewModel.displayMediaPreview(mediaPreviewViewModel) case .displayLocation(_, let geoURI, let description): actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description)) - case .viewInRoomTimeline(let eventID): - actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) + case .viewInRoomTimeline(let eventID, let threadRootEventID): + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID)) // These other actions will not be handled in this view case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift index 43aed5be9..73cbf3245 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift @@ -9,7 +9,7 @@ import Foundation enum PinnedEventsTimelineScreenViewModelAction { - case viewInRoomTimeline(itemID: TimelineItemIdentifier) + case viewInRoomTimeline(eventID: String, threadRootEventID: String?) case displayMessageForwarding(MessageForwardingItem) case dismiss } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift index 80238aea5..75cf1868f 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift @@ -12,14 +12,23 @@ import SwiftUI typealias PinnedEventsTimelineScreenViewModelType = StateStoreViewModel class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelType, PinnedEventsTimelineScreenViewModelProtocol { + private let roomProxy: JoinedRoomProxyProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + private let appSettings: AppSettings private let analyticsService: AnalyticsService private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - - init(analyticsService: AnalyticsService) { + + init(roomProxy: JoinedRoomProxyProtocol, + userIndicatorController: UserIndicatorControllerProtocol, + appSettings: AppSettings, + analyticsService: AnalyticsService) { + self.roomProxy = roomProxy + self.userIndicatorController = userIndicatorController + self.appSettings = appSettings self.analyticsService = analyticsService super.init(initialViewState: PinnedEventsTimelineScreenViewState()) } @@ -52,7 +61,10 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy self.actionsSubject.send(.displayMessageForwarding(forwardingItem)) } case .viewInRoomTimeline(let itemID): - actionsSubject.send(.viewInRoomTimeline(itemID: itemID)) + guard let eventID = itemID.eventID else { + return + } + Task { await self.viewInRoomTimeline(eventID: eventID) } case .dismiss: state.bindings.mediaPreviewViewModel = nil } @@ -61,4 +73,18 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy state.bindings.mediaPreviewViewModel = mediaPreviewViewModel } + + private func viewInRoomTimeline(eventID: String) async { + switch await roomProxy.loadOrFetchEventDetails(for: eventID) { + case .success(let event): + let threadRootEventID: String? = if appSettings.threadsEnabled { + event.threadRootEventId() + } else { + nil + } + actionsSubject.send(.viewInRoomTimeline(eventID: eventID, threadRootEventID: threadRootEventID)) + case .failure: + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + } + } } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 481bb5d97..13aeb5bf1 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -66,7 +66,11 @@ struct PinnedEventsTimelineScreen: View { // MARK: - Previews struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = PinnedEventsTimelineScreenViewModel(analyticsService: ServiceLocator.shared.analytics) + static let viewModel = PinnedEventsTimelineScreenViewModel(roomProxy: JoinedRoomProxyMock(.init()), + userIndicatorController: UserIndicatorControllerMock(), + appSettings: AppSettings(), + analyticsService: ServiceLocator.shared.analytics) + static let emptyTimelineViewModel: TimelineViewModel = { let timelineController = MockTimelineController(timelineKind: .pinned) timelineController.timelineItems = [] diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 75463f956..c482cca60 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -47,7 +47,7 @@ enum RoomScreenCoordinatorAction { case presentPinnedEventsTimeline case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case presentKnockRequestsList - case presentThread(itemID: TimelineItemIdentifier) + case presentThread(threadRootEventID: String, focussedEventID: String?) case presentRoom(roomID: String, via: [String]) } @@ -142,7 +142,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol { case .displayResolveSendFailure(let failure, let sendHandle): actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) case .displayThread(let itemID): - actionsSubject.send(.presentThread(itemID: itemID)) + guard let eventID = itemID.eventID else { + fatalError("A thread root has always an eventID") + } + actionsSubject.send(.presentThread(threadRootEventID: eventID, focussedEventID: nil)) case .composer(let action): composerViewModel.process(timelineAction: action) case .hasScrolled(direction: let direction): @@ -184,6 +187,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentRoom(roomID: roomID, via: via)) case .displayMessageForwarding(let forwardingItem): actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayThread(let threadRootEventID, let focussedEventID): + actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID)) } } .store(in: &cancellables) @@ -201,6 +206,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol { Task { await timelineViewModel.focusOnEvent(eventID: eventID) } } + /// Sets the banner to selection to a specific event ID, even if not visible in the main timeline (like a threaded event). + func setSelectedPin(eventID: String) { + roomViewModel.setSelectedPinnedEventID(eventID) + } + func shareText(_ string: String) { composerViewModel.process(timelineAction: .setMode(mode: .default)) // Make sure we're not e.g. replying. composerViewModel.process(timelineAction: .setText(plainText: string, htmlText: nil)) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 17fb838de..6f70de9a2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -11,6 +11,7 @@ import OrderedCollections enum RoomScreenViewModelAction: Equatable { case focusEvent(eventID: String) + case displayThread(threadRootEventID: String, focussedEventID: String) case displayPinnedEventsTimeline case displayRoomDetails case displayCall diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 8400efcbc..952d6adb3 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -85,11 +85,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol override func process(viewAction: RoomScreenViewAction) { switch viewAction { case .tappedPinnedEventsBanner: - analyticsService.trackInteraction(name: .PinnedMessageBannerClick) - if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID { - actionsSubject.send(.focusEvent(eventID: eventID)) - } - state.pinnedEventsBannerState.previousPin() + handleTappedPinnedEventsBanner() case .viewAllPins: analyticsService.trackInteraction(name: .PinnedMessageBannerViewAllButton) actionsSubject.send(.displayPinnedEventsTimeline) @@ -402,6 +398,27 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.handledEventIDs.subtract(failedIDs) } + private func handleTappedPinnedEventsBanner() { + analyticsService.trackInteraction(name: .PinnedMessageBannerClick) + if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID { + Task { + switch await roomProxy.loadOrFetchEventDetails(for: eventID) { + case .success(let event): + if appSettings.threadsEnabled, + let threadRootEventID = event.threadRootEventId() { + actionsSubject.send(.focusEvent(eventID: threadRootEventID)) + actionsSubject.send(.displayThread(threadRootEventID: threadRootEventID, focussedEventID: eventID)) + } else { + actionsSubject.send(.focusEvent(eventID: eventID)) + } + case .failure: + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + } + } + } + state.pinnedEventsBannerState.previousPin() + } + // MARK: Loading indicators private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading" diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index c9f12e8b9..024cb5ced 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -28,7 +28,7 @@ enum TimelineViewModelAction { case displayThread(itemID: TimelineItemIdentifier) case composer(action: TimelineComposerAction) case hasScrolled(direction: ScrollDirection) - case viewInRoomTimeline(eventID: String) + case viewInRoomTimeline(eventID: String, threadRootEventID: String?) case displayRoom(roomID: String, via: [String]) case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol) } diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 60d05f808..2a469fc89 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -495,7 +495,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { case .showDebugInfo(let debugInfo): state.bindings.debugInfo = debugInfo case .viewInRoomTimeline(let eventID): - actionsSubject.send(.viewInRoomTimeline(eventID: eventID)) + Task { await self.viewInRoomTimeline(eventID: eventID) } case .displayThread(let itemID): actionsSubject.send(.displayThread(itemID: itemID)) } @@ -503,6 +503,20 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { .store(in: &cancellables) } + func viewInRoomTimeline(eventID: String) async { + switch await roomProxy.loadOrFetchEventDetails(for: eventID) { + case .success(let event): + let threadRootEventID: String? = if appSettings.threadsEnabled { + event.threadRootEventId() + } else { + nil + } + actionsSubject.send(.viewInRoomTimeline(eventID: eventID, threadRootEventID: threadRootEventID)) + case .failure: + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + } + } + private func setupAppSettingsSubscriptions() { appSettings.$sharePresence .weakAssign(to: \.state.showReadReceipts, on: self) diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 9d5caa7a4..c99bc693c 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -22,6 +22,7 @@ class RoomScreenViewModelTests: XCTestCase { override func tearDown() { viewModel = nil + AppSettings.resetAllSettings() } func testPinnedEventsBanner() async throws { @@ -109,6 +110,7 @@ class RoomScreenViewModelTests: XCTestCase { func testPinnedEventsBannerSelection() async throws { let roomProxyMock = JoinedRoomProxyMock(.init()) + roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(TimelineEventSDKMock()) // setup a way to inject the mock of the pinned events timeline let pinnedTimelineMock = TimelineProxyMock() let pinnedTimelineItemProviderMock = TimelineItemProviderMock() @@ -160,6 +162,67 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } + func testPinnedEventsBannerThreadedSelection() async throws { + ServiceLocator.shared.settings.threadsEnabled = true + + let roomProxyMock = JoinedRoomProxyMock(.init()) + let eventMock = TimelineEventSDKMock() + eventMock.threadRootEventIdReturnValue = "thread" + roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(eventMock) + + // setup a way to inject the mock of the pinned events timeline + let pinnedTimelineMock = TimelineProxyMock() + let pinnedTimelineItemProviderMock = TimelineItemProviderMock() + pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock + pinnedTimelineItemProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], PaginationState), Never>().eraseToAnyPublisher() + pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))), + .event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))), + .event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))] + roomProxyMock.pinnedEventsTimelineReturnValue = .success(pinnedTimelineMock) + + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: "test1", + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + self.viewModel = viewModel + + // check if the banner is now in a loaded state and is showing the counter + var deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + !viewState.pinnedEventsBannerState.isLoading + } + try await deferred.fulfill() + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3) + XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + // And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0) + + // check if the banner scrolls when tapping the previous pin + deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + viewState.pinnedEventsBannerState.selectedPinnedIndex == 2 + } + let deferredAction1 = deferFulfillment(viewModel.actions) { action in + if case let .focusEvent(threadRootEventID) = action { + return threadRootEventID == "thread" + } + return false + } + let deferredAction2 = deferFulfillment(viewModel.actions) { action in + if case let .displayThread(threadRootEventID, focussedEventID) = action { + return threadRootEventID == "thread" && focussedEventID == "test1" + } + return false + } + + viewModel.context.send(viewAction: .tappedPinnedEventsBanner) + try await deferred.fulfill() + try await deferredAction1.fulfill() + try await deferredAction2.fulfill() + } + func testRoomInfoUpdate() async throws { var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false) let roomProxyMock = JoinedRoomProxyMock(configuration)