diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e90bfdb6a..95d2687f7 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 08CB4BD12CEEDE6AAE4A18DD /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035177BCD8E8308B098AC3C2 /* WindowManager.swift */; }; 095C0ACFC234E0550A6404C5 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */; }; 095D3906CF2F940C2D2D17CC /* RoomFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */; }; + 09693596AA3CA4E598C0D2F1 /* ThreadTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FEE625AB52042049DB9268 /* ThreadTimelineScreenCoordinator.swift */; }; 09713669577CDA8D012EE380 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6647C55D93508C7CE9D954A5 /* MatrixRustSDK */; }; 09D3D7D115318CAD131B4FE7 /* ResolveVerifiedUserSendFailureScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57084488B03BDB33C7B7CA0E /* ResolveVerifiedUserSendFailureScreenViewModelTests.swift */; }; 0A194F5E70B5A628C1BF4476 /* AdvancedSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */; }; @@ -483,6 +484,7 @@ 5DB4334CBBA142376FF5FFEC /* preview_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 200626E8353AB2729444F991 /* preview_image.jpg */; }; 5DD85A0FE3D85AEC3C7EFE36 /* DeveloperOptionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */; }; 5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C537DE821FED94D23467B6C4 /* PillUtilities.swift */; }; + 5EB116B58533C9A0EBA22717 /* ThreadTimelineScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2E7CEE6E9314FD69FE0ED9 /* ThreadTimelineScreen.swift */; }; 5EC046E41755C095DAB1C3FF /* TimelineProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8C9BBB729C941BEE0E2A63 /* TimelineProviderProtocol.swift */; }; 5EDBDE802761B5ECB54E6787 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2711E5996016ABD6EAAEB58A /* LogLevel.swift */; }; 5F06AD3C66884CE793AE6119 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; @@ -586,6 +588,7 @@ 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; 7254FB2EFDD43BC8BB7A1213 /* SecurityAndPrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AE42C19EDE64B7CB7BE4D0 /* SecurityAndPrivacyScreen.swift */; }; 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; }; + 738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */; }; 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; @@ -703,6 +706,7 @@ 877D3CE8680536DB430DE6A2 /* TimelineItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E48C91C8BE55CAE1A3DBC3BC /* TimelineItemIdentifier.swift */; }; 878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */; }; 87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */; }; + 87E8C31FCF9276F15CB0B408 /* ThreadTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1C19A4BE46EDE1411ECCE /* ThreadTimelineScreenViewModelProtocol.swift */; }; 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */; }; 88356DE7F2AD243AB10C7B7A /* Signposter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */; }; 887AC93C523AEFB640EA5EC8 /* TextBasedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E33FD32BBC44D703C7AE4F9 /* TextBasedRoomTimelineItem.swift */; }; @@ -850,6 +854,7 @@ A3D7110C1E75E7B4A73BE71C /* VoiceMessageRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */; }; A439B456D0761D6541745CC3 /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; }; A440D4BC02088482EC633A88 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; + A46B93EA564F539CEC252ECC /* ThreadTimelineScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69AEA8755382DB34892FB7B /* ThreadTimelineScreenModels.swift */; }; A494741843F087881299ACF0 /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; }; A4AF12D9D8BA34B3B7B55B08 /* AuthenticationStartScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6663BFB9FDB8752562CD12CA /* AuthenticationStartScreenCoordinator.swift */; }; A4B0BAD62A12ED76BD611B79 /* BadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FA515B3B0D61EF1E907D2D /* BadgeView.swift */; }; @@ -1924,6 +1929,7 @@ 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportFlowCoordinator.swift; sourceTree = ""; }; 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPreviewProxyProtocol.swift; sourceTree = ""; }; 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingComposer.swift; sourceTree = ""; }; + 73FEE625AB52042049DB9268 /* ThreadTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenCoordinator.swift; sourceTree = ""; }; 7447C0AD7EF302CD027D6230 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SAS.strings; sourceTree = ""; }; 7463464054DDF194C54F0B04 /* LogViewerScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenViewModelProtocol.swift; sourceTree = ""; }; 74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenCoordinator.swift; sourceTree = ""; }; @@ -1963,6 +1969,7 @@ 7C1AF829F12FDC99717082D9 /* RoomRolesAndPermissionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenViewModel.swift; sourceTree = ""; }; 7C28B70BEFD3676F11D5D51F /* RoomRolesAndPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenCoordinator.swift; sourceTree = ""; }; 7C71B9802433F1B4252291BB /* IdentityConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; + 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenViewModel.swift; sourceTree = ""; }; 7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactoryProtocol.swift; sourceTree = ""; }; 7D39AF1F659923D77778511E /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2262,6 +2269,7 @@ B6404166CBF5CC88673FF9E2 /* RoomDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetails.swift; sourceTree = ""; }; B655A536341D2695158C6664 /* AuthenticationClientBuilderFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationClientBuilderFactory.swift; sourceTree = ""; }; B68B31232312AFC844440BFE /* DeclineAndBlockScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenModels.swift; sourceTree = ""; }; + B69AEA8755382DB34892FB7B /* ThreadTimelineScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenModels.swift; sourceTree = ""; }; B6A293D06BAB2B7A17D9314B /* VoiceMessageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRoomTimelineView.swift; sourceTree = ""; }; B6C585CE1F721A2770C70D47 /* TimelineControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineControllerProtocol.swift; sourceTree = ""; }; B6E4AB573FAEBB7B853DD04C /* AppHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHooks.swift; sourceTree = ""; }; @@ -2289,6 +2297,7 @@ BB6ED50FE104992419310EEB /* NotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHandler.swift; sourceTree = ""; }; BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenCoordinator.swift; sourceTree = ""; }; BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderProtocol.swift; sourceTree = ""; }; + BC2E7CEE6E9314FD69FE0ED9 /* ThreadTimelineScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreen.swift; sourceTree = ""; }; BC51BF90469412ABDE658CDD /* portrait_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = portrait_test_image.jpg; sourceTree = ""; }; BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMocks.swift; sourceTree = ""; }; BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemDebugView.swift; sourceTree = ""; }; @@ -2330,6 +2339,7 @@ C33B3F17996DFDF5F0181512 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; C352359663A0E52BA20761EE /* LoadableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableImage.swift; sourceTree = ""; }; C4756240773D26AB74C22668 /* OrientationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrientationManagerProtocol.swift; sourceTree = ""; }; + C4C1C19A4BE46EDE1411ECCE /* ThreadTimelineScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenViewModelProtocol.swift; sourceTree = ""; }; C4C89820BB2B88D4EA28131C /* BugReportScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelProtocol.swift; sourceTree = ""; }; C4CD503F5E0938FE53C7C6E7 /* UserDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenCoordinator.swift; sourceTree = ""; }; C537DE821FED94D23467B6C4 /* PillUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillUtilities.swift; sourceTree = ""; }; @@ -4977,6 +4987,18 @@ path = AppLock; sourceTree = ""; }; + 9688AEF931DE2C71683ACBC7 /* ThreadTimelineScreen */ = { + isa = PBXGroup; + children = ( + 73FEE625AB52042049DB9268 /* ThreadTimelineScreenCoordinator.swift */, + B69AEA8755382DB34892FB7B /* ThreadTimelineScreenModels.swift */, + 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */, + C4C1C19A4BE46EDE1411ECCE /* ThreadTimelineScreenViewModelProtocol.swift */, + A55B1545241E40A06958DDF7 /* View */, + ); + path = ThreadTimelineScreen; + sourceTree = ""; + }; 99B9B46F2D621380428E68F7 /* ElementX */ = { isa = PBXGroup; children = ( @@ -5120,6 +5142,14 @@ path = UnitTests; sourceTree = ""; }; + A55B1545241E40A06958DDF7 /* View */ = { + isa = PBXGroup; + children = ( + BC2E7CEE6E9314FD69FE0ED9 /* ThreadTimelineScreen.swift */, + ); + path = View; + sourceTree = ""; + }; A6AA0A048CAE428A5CA4CBBB /* LayoutTests */ = { isa = PBXGroup; children = ( @@ -5814,6 +5844,7 @@ C59BA103987B953BA374509F /* SecurityAndPrivacyScreen */, 70B74A432C241E56A7ACE610 /* Settings */, EC4545C7E37E8294D3FE6800 /* StartChatScreen */, + 9688AEF931DE2C71683ACBC7 /* ThreadTimelineScreen */, 15D44FCA9475E660B7F56DB9 /* Timeline */, 93C7520ED23C9598BB144DBB /* UserProfileScreen */, ); @@ -7688,6 +7719,11 @@ 53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */, D8CFA0EE46376F9FF04EEE45 /* TextRoomTimelineView.swift in Sources */, 71643093F87153F633A1B025 /* ThreadDecorator.swift in Sources */, + 5EB116B58533C9A0EBA22717 /* ThreadTimelineScreen.swift in Sources */, + 09693596AA3CA4E598C0D2F1 /* ThreadTimelineScreenCoordinator.swift in Sources */, + A46B93EA564F539CEC252ECC /* ThreadTimelineScreenModels.swift in Sources */, + 738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */, + 87E8C31FCF9276F15CB0B408 /* ThreadTimelineScreenViewModelProtocol.swift in Sources */, 2AED12987603157C32C2114D /* TimelineBubbleLayout.swift in Sources */, 2BFA4C6D5B3D327B02C66AB0 /* TimelineController.swift in Sources */, 7DCFC31B49BDD2BC32184E58 /* TimelineControllerFactory.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 2bc5d599d..0caa40a36 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -302,6 +302,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (_, .dismissFlow): return .complete + case (_, .presentThread(let itemID)): + return .thread(itemID: itemID) + case (_, .dismissThread): + return .room + case (.initial, .presentRoomDetails): return .roomDetails(isRoot: true) case (.room, .presentRoomDetails): @@ -462,6 +467,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } case (_, .dismissFlow, .complete): dismissFlow(animated: animated) + + case (.room, .presentThread(let itemID), .thread): + Task { await self.presentThread(itemID: itemID) } + case (.thread, .dismissThread, .room): + break case (.initial, .presentRoomDetails, .roomDetails(let isRoot)), (.room, .presentRoomDetails, .roomDetails(let isRoot)), @@ -618,7 +628,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { presentDeclineAndBlockScreen(userID: userID) case (.declineAndBlockScreen, .dismissDeclineAndBlockScreen, .joinRoomScreen): break - + // Child flow case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild): Task { await self.startChildFlow(for: roomID, via: via, entryPoint: entryPoint) } @@ -782,6 +792,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) case .presentKnockRequestsList: stateMachine.tryEvent(.presentKnockRequestsListScreen) + case .presentThread(let itemID): + stateMachine.tryEvent(.presentThread(itemID: itemID)) } } .store(in: &cancellables) @@ -789,6 +801,41 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return coordinator } + private func presentThread(itemID: TimelineItemIdentifier) async { + showLoadingIndicator() + defer { hideLoadingIndicator() } + + let timelineItemFactory = RoomTimelineItemFactory(userID: userSession.clientProxy.userID, + attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) + + guard let eventID = itemID.eventID else { + fatalError("Invalid thread event ID") + } + + guard case let .success(timelineController) = await timelineControllerFactory.buildThreadTimelineController(eventID: eventID, + roomProxy: roomProxy, + timelineItemFactory: timelineItemFactory, + mediaProvider: userSession.mediaProvider) else { + MXLog.error("Failed presenting media timeline") + return + } + + let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(roomProxy: roomProxy, + timelineController: timelineController, + mediaProvider: userSession.mediaProvider, + mediaPlayerProvider: MediaPlayerProvider(), + voiceMessageMediaManager: userSession.voiceMessageMediaManager, + appMediator: appMediator, + emojiProvider: emojiProvider, + timelineControllerFactory: timelineControllerFactory, + clientProxy: userSession.clientProxy)) + + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissThread) + } + } + private func presentJoinRoomScreen(via: [String], animated: Bool) { let coordinator = JoinRoomScreenCoordinator(parameters: .init(roomID: roomID, via: via, @@ -1747,6 +1794,7 @@ private extension RoomFlowCoordinator { case initial case joinRoomScreen case room + case thread(itemID: TimelineItemIdentifier) case roomDetails(isRoot: Bool) case roomDetailsEditScreen case notificationSettings @@ -1790,6 +1838,9 @@ private extension RoomFlowCoordinator { case presentRoom(presentationAction: PresentationAction?) case dismissFlow + case presentThread(itemID: TimelineItemIdentifier) + case dismissThread + case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) case dismissReportContent diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 11a049ebc..92d457843 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -6438,6 +6438,76 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { return timelineFocusedOnEventEventIDNumberOfEventsReturnValue } } + //MARK: - threadTimeline + + var threadTimelineEventIDUnderlyingCallsCount = 0 + var threadTimelineEventIDCallsCount: Int { + get { + if Thread.isMainThread { + return threadTimelineEventIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = threadTimelineEventIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + threadTimelineEventIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + threadTimelineEventIDUnderlyingCallsCount = newValue + } + } + } + } + var threadTimelineEventIDCalled: Bool { + return threadTimelineEventIDCallsCount > 0 + } + var threadTimelineEventIDReceivedEventID: String? + var threadTimelineEventIDReceivedInvocations: [String] = [] + + var threadTimelineEventIDUnderlyingReturnValue: Result! + var threadTimelineEventIDReturnValue: Result! { + get { + if Thread.isMainThread { + return threadTimelineEventIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = threadTimelineEventIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + threadTimelineEventIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + threadTimelineEventIDUnderlyingReturnValue = newValue + } + } + } + } + var threadTimelineEventIDClosure: ((String) async -> Result)? + + func threadTimeline(eventID: String) async -> Result { + threadTimelineEventIDCallsCount += 1 + threadTimelineEventIDReceivedEventID = eventID + DispatchQueue.main.async { + self.threadTimelineEventIDReceivedInvocations.append(eventID) + } + if let threadTimelineEventIDClosure = threadTimelineEventIDClosure { + return await threadTimelineEventIDClosure(eventID) + } else { + return threadTimelineEventIDReturnValue + } + } //MARK: - messageFilteredTimeline var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = 0 @@ -14969,6 +15039,76 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck return buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue } } + //MARK: - buildThreadTimelineController + + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { + get { + if Thread.isMainThread { + return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + } + } + } + } + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { + return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 + } + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (eventID: String, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(eventID: String, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] + + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result! + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result! { + get { + if Thread.isMainThread { + return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + } + } + } + } + var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure: ((String, JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result)? + + func buildThreadTimelineController(eventID: String, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result { + buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 + buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (eventID: eventID, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) + DispatchQueue.main.async { + self.buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((eventID: eventID, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) + } + if let buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure = buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure { + return await buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure(eventID, roomProxy, timelineItemFactory, mediaProvider) + } else { + return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReturnValue + } + } //MARK: - buildPinnedEventsTimelineController var buildPinnedEventsTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index ff19c183c..798adbd09 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -81,7 +81,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, .displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, - .composer, .hasScrolled, .viewInRoomTimeline: + .displayThread, .composer, .hasScrolled, .viewInRoomTimeline: break } } @@ -103,7 +103,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, .displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, - .composer, .hasScrolled, .viewInRoomTimeline: + .displayThread, .composer, .hasScrolled, .viewInRoomTimeline: break } } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift index 61ea85487..4a09b7782 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift @@ -91,7 +91,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { // These other actions will not be handled in this view case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, - .displayResolveSendFailure, .composer, .hasScrolled: + .displayResolveSendFailure, .displayThread, .composer, .hasScrolled: // These actions are not handled in this coordinator break } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index f2a2c9e02..e069fb392 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -44,6 +44,7 @@ enum RoomScreenCoordinatorAction { case presentPinnedEventsTimeline case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case presentKnockRequestsList + case presentThread(itemID: TimelineItemIdentifier) } final class RoomScreenCoordinator: CoordinatorProtocol { @@ -144,6 +145,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description)) case .displayResolveSendFailure(let failure, let sendHandle): actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) + case .displayThread(let itemID): + actionsSubject.send(.presentThread(itemID: itemID)) case .composer(let action): composerViewModel.process(timelineAction: action) case .hasScrolled(direction: let direction): diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift new file mode 100644 index 000000000..7efaa00fe --- /dev/null +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -0,0 +1,93 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +struct ThreadTimelineScreenCoordinatorParameters { + let roomProxy: JoinedRoomProxyProtocol + let timelineController: TimelineControllerProtocol + let mediaProvider: MediaProviderProtocol + let mediaPlayerProvider: MediaPlayerProviderProtocol + let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol + let appMediator: AppMediatorProtocol + let emojiProvider: EmojiProviderProtocol + let timelineControllerFactory: TimelineControllerFactoryProtocol + let clientProxy: ClientProxyProtocol +} + +enum ThreadTimelineScreenCoordinatorAction { + case dismiss + case displayUser(userID: String) + case presentLocationViewer(geoURI: GeoURI, description: String?) + case displayMessageForwarding(forwardingItem: MessageForwardingItem) + case displayRoomScreenWithFocussedPin(eventID: String) +} + +final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { + private let parameters: ThreadTimelineScreenCoordinatorParameters + private let viewModel: ThreadTimelineScreenViewModelProtocol + private let timelineViewModel: TimelineViewModelProtocol + + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: ThreadTimelineScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = ThreadTimelineScreenViewModel() + timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, + timelineController: parameters.timelineController, + mediaProvider: parameters.mediaProvider, + mediaPlayerProvider: parameters.mediaPlayerProvider, + voiceMessageMediaManager: parameters.voiceMessageMediaManager, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: parameters.appMediator, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + emojiProvider: parameters.emojiProvider, + timelineControllerFactory: parameters.timelineControllerFactory, + clientProxy: parameters.clientProxy) + } + + func start() { + timelineViewModel.actions.sink { [weak self] action in + MXLog.info("Coordinator: received timeline view model action: \(action)") + guard let self else { return } + + switch action { + case .displaySenderDetails(let userID): + actionsSubject.send(.displayUser(userID: userID)) + case .displayMessageForwarding(let forwardingItem): + actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) + case .displayLocation(_, let geoURI, let description): + actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description)) + case .viewInRoomTimeline(let eventID): + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) + // These other actions will not be handled in this view + case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, + .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaPreview, + .displayMediaUploadPreviewScreen, .displayResolveSendFailure, .displayThread, .composer, .hasScrolled: + // These actions are not handled in this coordinator + break + } + } + .store(in: &cancellables) + } + + func stop() { + viewModel.stop() + } + + func toPresentable() -> AnyView { + AnyView(ThreadTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift new file mode 100644 index 000000000..d4bc7c619 --- /dev/null +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift @@ -0,0 +1,21 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum ThreadTimelineScreenViewModelAction { } + +struct ThreadTimelineScreenViewState: BindableState { + var bindings = ThreadTimelineScreenViewStateBindings() +} + +struct ThreadTimelineScreenViewStateBindings { + /// The view model used to present a QuickLook media preview. + var mediaPreviewViewModel: TimelineMediaPreviewViewModel? +} + +enum ThreadTimelineScreenViewAction { } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift new file mode 100644 index 000000000..91ff82083 --- /dev/null +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift @@ -0,0 +1,31 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +typealias ThreadTimelineScreenViewModelType = StateStoreViewModel + +class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTimelineScreenViewModelProtocol { + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init() { + super.init(initialViewState: ThreadTimelineScreenViewState()) + } + + // MARK: - Public + + override func process(viewAction: ThreadTimelineScreenViewAction) { } + + func stop() { + // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. + state.bindings.mediaPreviewViewModel = nil + } +} diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModelProtocol.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModelProtocol.swift new file mode 100644 index 000000000..be5dc5e9f --- /dev/null +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModelProtocol.swift @@ -0,0 +1,16 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine + +@MainActor +protocol ThreadTimelineScreenViewModelProtocol { + var actionsPublisher: AnyPublisher { get } + var context: ThreadTimelineScreenViewModelType.Context { get } + + func stop() +} diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift new file mode 100644 index 000000000..1479c8f4c --- /dev/null +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -0,0 +1,51 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct ThreadTimelineScreen: View { + @ObservedObject var context: ThreadTimelineScreenViewModel.Context + @ObservedObject var timelineContext: TimelineViewModel.Context + + var body: some View { + content + .navigationTitle("Thread") + .navigationBarTitleDisplayMode(.inline) + .background(.compound.bgCanvasDefault) + .interactiveDismissDisabled() + .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) + .sheet(item: $timelineContext.manageMemberViewModel) { + ManageRoomMemberSheetView(context: $0.context) + } + .sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) } + .sheet(item: $timelineContext.actionMenuInfo) { info in + let actions = TimelineItemMenuActionProvider(timelineItem: info.item, + canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, + canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers, + canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, + pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, + isDM: timelineContext.viewState.isDirectOneToOneRoom, + isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, + timelineKind: timelineContext.viewState.timelineKind, + emojiProvider: timelineContext.viewState.emojiProvider) + .makeActions() + if let actions { + TimelineItemMenu(item: info.item, actions: actions) + .environmentObject(timelineContext) + } + } + } + + @ViewBuilder + private var content: some View { + TimelineView() + .id(timelineContext.viewState.roomID) + .environmentObject(timelineContext) + .environment(\.focussedEventID, timelineContext.viewState.timelineState.focussedEvent?.eventID) + } +} diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 5c7bfbb34..98102ee11 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -554,7 +554,7 @@ class TimelineInteractionHandler { case .pinned: newTimelineFocus = .pinned newTimelinePresentation = .pinnedEventsScreen - case .media: + case .media, .thread: break // We don't need to create a new timeline as it is already filtered. } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 1996a1279..151909e2f 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -24,6 +24,7 @@ enum TimelineViewModelAction { case displayMediaPreview(TimelineMediaPreviewViewModel) case displayLocation(body: String, geoURI: GeoURI, description: String?) case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) + case displayThread(itemID: TimelineItemIdentifier) case composer(action: TimelineComposerAction) case hasScrolled(direction: ScrollDirection) case viewInRoomTimeline(eventID: String) diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 61f0ad903..67cec5ec7 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -191,8 +191,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { displayReactionSummary(for: itemID, selectedKey: key) case .displayReadReceipts(let itemID): displayReadReceipts(for: itemID) - case .displayThread: - break + case .displayThread(let itemID): + actionsSubject.send(.displayThread(itemID: itemID)) case .handlePasteOrDrop(let provider): timelineInteractionHandler.handlePasteOrDrop(provider) case .handlePollAction(let pollAction): diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift index 8c779bbc0..a7f888b03 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -111,7 +111,7 @@ struct TimelineItemMenuActionProvider { actions.append(.save) actions = actions.filter(\.canAppearInMediaDetails) secondaryActions = secondaryActions.filter(\.canAppearInMediaDetails) - case .live, .detached: + case .live, .detached, .thread: break // viewInRoomTimeline is the only non-room item and was added conditionally. } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index 8a47b0322..e8f648242 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -120,7 +120,7 @@ struct TimelineItemBubbledStylerView: View { .onTapGesture { } } - if let threadSummary = timelineItem.properties.threadSummary { + if context.viewState.timelineKind != .thread, let threadSummary = timelineItem.properties.threadSummary { TimelineThreadSummaryView(threadSummary: threadSummary) { context.send(viewAction: .displayThread(itemID: timelineItem.id)) } @@ -177,13 +177,13 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var contentWithReply: some View { TimelineBubbleLayout(spacing: 8) { - if timelineItem.properties.isThreaded { + if context.viewState.timelineKind != .thread, timelineItem.properties.isThreaded { ThreadDecorator() .padding(.leading, 4) .layoutPriority(TimelineBubbleLayout.Priority.regularText) } - if let replyDetails = timelineItem.properties.replyDetails { + if shouldShowReplyDetails, let replyDetails = timelineItem.properties.replyDetails { // The rendered reply bubble with a greedy width. The custom layout prevents // the infinite width from increasing the overall width of the view. @@ -212,6 +212,10 @@ struct TimelineItemBubbledStylerView: View { } } + private var shouldShowReplyDetails: Bool { + !timelineItem.properties.isThreaded || (timelineItem.properties.isThreaded && context.viewState.timelineKind != .thread) + } + private var messageBubbleTopPadding: CGFloat { guard timelineItem.isOutgoing || isDirectOneToOneRoom else { return 0 } return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0 diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 62bc3b029..4fe9e2def 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -190,6 +190,24 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } + func threadTimeline(eventID: String) async -> Result { + do { + let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .thread(rootEventId: eventID, numEvents: 20), + filter: .all, + internalIdPrefix: UUID().uuidString, + dateDividerMode: .daily, + trackReadReceipts: true)) + + let timeline = TimelineProxy(timeline: sdkTimeline, kind: .thread) + await timeline.subscribeForUpdates() + + return .success(timeline) + } catch { + MXLog.error("Unexpected error: \(error)") + return .failure(.sdkError(error)) + } + } + func messageFilteredTimeline(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result { @@ -197,6 +215,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { let rustFocus: MatrixRustSDK.TimelineFocus = switch focus { case .live: .live case .eventID(let eventID): .event(eventId: eventID, numContextEvents: 100) + case .thread(let eventID): .thread(rootEventId: eventID, numEvents: 20) case .pinned: .pinnedEvents(maxEventsToLoad: 100, maxConcurrentRequests: 10) } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 5d4f31f08..67e073445 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -83,6 +83,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result + func threadTimeline(eventID: String) async -> Result + func messageFilteredTimeline(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift index f8c5f8185..dee4c4d03 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift @@ -21,6 +21,23 @@ struct TimelineControllerFactory: TimelineControllerFactoryProtocol { appSettings: ServiceLocator.shared.settings) } + func buildThreadTimelineController(eventID: String, + roomProxy: JoinedRoomProxyProtocol, + timelineItemFactory: RoomTimelineItemFactoryProtocol, + mediaProvider: MediaProviderProtocol) async -> Result { + switch await roomProxy.threadTimeline(eventID: eventID) { + case .success(let timelineProxy): + return .success(TimelineController(roomProxy: roomProxy, + timelineProxy: timelineProxy, + initialFocussedEventID: nil, + timelineItemFactory: timelineItemFactory, + mediaProvider: mediaProvider, + appSettings: ServiceLocator.shared.settings)) + case .failure(let error): + return .failure(.roomProxyError(error)) + } + } + func buildPinnedEventsTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol? { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift index 882b57be1..e9c9c53ce 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift @@ -19,6 +19,11 @@ protocol TimelineControllerFactoryProtocol { timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) -> TimelineControllerProtocol + func buildThreadTimelineController(eventID: String, + roomProxy: JoinedRoomProxyProtocol, + timelineItemFactory: RoomTimelineItemFactoryProtocol, + mediaProvider: MediaProviderProtocol) async -> Result + func buildPinnedEventsTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol? diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index a35fbef57..6e418eccc 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -97,7 +97,7 @@ final class TimelineProxy: TimelineProxyProtocol { switch kind { case .live: return await paginateBackwardsOnLive(requestSize: requestSize) - case .detached, .media: + case .detached, .media, .thread: return await focussedPaginate(.backwards, requestSize: requestSize) case .pinned: return .success(()) @@ -596,7 +596,7 @@ final class TimelineProxy: TimelineProxyProtocol { MXLog.error("Failed to subscribe to back pagination status with error: \(error)") } forwardPaginationStatusSubject.send(.timelineEndReached) - case .detached: + case .detached, .thread: // Detached timelines don't support observation, set the initial state ourself. backPaginationStatusSubject.send(.idle) forwardPaginationStatusSubject.send(.idle) diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index 337146487..55b28377c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -13,6 +13,7 @@ enum TimelineKind: Equatable { case live case detached case pinned + case thread enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen } case media(MediaPresentation) @@ -21,6 +22,7 @@ enum TimelineKind: Equatable { enum TimelineFocus { case live case eventID(String) + case thread(eventID: String) case pinned }