diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3499197ee..c2fc06abe 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -127,6 +127,7 @@ 12CD8B5CC30A05061228BF9E /* TimelineItemMenuActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E6065FC6BC4A1B4C629E08 /* TimelineItemMenuActionProvider.swift */; }; 12E6D052D055531A6783E21B /* portrait_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = BC51BF90469412ABDE658CDD /* portrait_test_image.jpg */; }; 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */; }; + 12F7172C93873638B23CD6AD /* RoomThreadListServiceProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EB6035C1F33F25F1EBFB7D /* RoomThreadListServiceProxyProtocol.swift */; }; 1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638790D3F915F0909315C47A /* PollView.swift */; }; 1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */; }; 13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; }; @@ -337,6 +338,7 @@ 388D39ED9FE1122EA6D76BF2 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BC84BA0AF11C2128D58ABD /* Common.swift */; }; 3895969759E68FAB90C63EF7 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; 38E0F9A4CB5237F039A2EEE6 /* RoomHistorySharingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */; }; + 38E8F5CC90DC0D716B799DE1 /* RoomThreadListServiceProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */; }; 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */; }; 3982E60F9C126437D5E488A3 /* PillContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A6314FDC51DA25712D9A81 /* PillContextTests.swift */; }; 39A987B3E41B976D1DF944C6 /* CallScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */; }; @@ -654,6 +656,7 @@ 70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; }; 706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 707E49BE07E8EB8A13C0EB1E /* SessionVerificationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FACD034DB52525A3CEF2BDF /* SessionVerificationScreen.swift */; }; + 708FC3184CCED825F0A36273 /* RoomThreadListServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE06F4A71FD46C9D8CD432E /* RoomThreadListServiceProxy.swift */; }; 709A9B52FC26B4CB86B8B020 /* EncryptedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */; }; 70B83D44043293B4B77440B9 /* PollFormScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB63761D9F9CE8B23CBD6179 /* PollFormScreenModels.swift */; }; 71643093F87153F633A1B025 /* ThreadDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA4F09CB613C54FDC73AE6A /* ThreadDecorator.swift */; }; @@ -1602,6 +1605,7 @@ 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = ""; }; 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; + 04EB6035C1F33F25F1EBFB7D /* RoomThreadListServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListServiceProxyProtocol.swift; sourceTree = ""; }; 0516C69708D5CBDE1A8E77EC /* RoomDirectorySearchProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchProxyProtocol.swift; sourceTree = ""; }; 052A8C0B00FE8AD7C972F2A4 /* LocationSharingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingScreenViewModel.swift; sourceTree = ""; }; 052B2F924572AFD70B5F500E /* StartChatScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenViewModel.swift; sourceTree = ""; }; @@ -2003,7 +2007,7 @@ 4A2B5274C1D3D2999D643786 /* EncryptionResetPasswordScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenViewModelProtocol.swift; sourceTree = ""; }; 4A5B4CD611DE7E94F5BA87B2 /* AppLockTimerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTimerTests.swift; sourceTree = ""; }; 4AB29A2D95D3469B5F016655 /* SecureBackupControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupControllerMock.swift; sourceTree = ""; }; - 4B1F71AC585827E6C416C15A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 4B1F71AC585827E6C416C15A /* AppIcon.icon */ = {isa = PBXFileReference; path = AppIcon.icon; sourceTree = ""; }; 4B2B564CA6570E1487A7C7CC /* SpaceRoomListProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomListProxy.swift; sourceTree = ""; }; 4B2D4EEBE8C098BBADD10939 /* SecureBackupKeyBackupScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupKeyBackupScreenCoordinator.swift; sourceTree = ""; }; 4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = ""; }; @@ -2203,6 +2207,7 @@ 7310D8DFE01AF45F0689C3AA /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportFlowCoordinator.swift; sourceTree = ""; }; 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPreviewProxyProtocol.swift; sourceTree = ""; }; + 7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListServiceProxyMock.swift; sourceTree = ""; }; 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingComposer.swift; sourceTree = ""; }; 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = archive.ar; path = libMatrixRustSDKMocks.a; sourceTree = BUILT_PRODUCTS_DIR; }; 73FEE625AB52042049DB9268 /* ThreadTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenCoordinator.swift; sourceTree = ""; }; @@ -2708,6 +2713,7 @@ CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarHeaderView.swift; sourceTree = ""; }; CC8E9ECC1118A92FAD5191CD /* SpaceAddRoomsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreen.swift; sourceTree = ""; }; CCAA1B97A17A750AC706B112 /* SpaceServiceRoom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceRoom.swift; sourceTree = ""; }; + CCE06F4A71FD46C9D8CD432E /* RoomThreadListServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListServiceProxy.swift; sourceTree = ""; }; CCF71646898A2F720C5BFDF5 /* RoomDirectorySearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenViewModel.swift; sourceTree = ""; }; CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreen.swift; sourceTree = ""; }; CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -3714,6 +3720,7 @@ E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */, 6695C64F066628411EAD21E9 /* RoomPreviewProxyMock.swift */, FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */, + 7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */, 4AB29A2D95D3469B5F016655 /* SecureBackupControllerMock.swift */, 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */, 7DB2A5FF1E68BA580A20D405 /* SpaceRoomListProxyMock.swift */, @@ -4087,6 +4094,8 @@ CFFA5E881D281810AB428EA3 /* RoomPowerLevelsProxy.swift */, 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */, 578AF9CE60816069536C0953 /* RoomRole.swift */, + CCE06F4A71FD46C9D8CD432E /* RoomThreadListServiceProxy.swift */, + 04EB6035C1F33F25F1EBFB7D /* RoomThreadListServiceProxyProtocol.swift */, 2C0F49BD446849654C0D24E0 /* RoomMember */, 4DC0344D2EBD0AE5D71754A9 /* RoomMembershipDetails */, 7FC3F8FA5EA765AC3B000F55 /* RoomPreview */, @@ -7250,6 +7259,7 @@ }; }; buildConfigurationList = 7AE41FCCF9D1352E2770D1F9 /* Build configuration list for PBXProject "ElementX" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -7325,7 +7335,6 @@ C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */, ); preferredProjectObjectVersion = 77; - productRefGroup = 681566846AF307E9BA4C72C6 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -8649,6 +8658,9 @@ 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */, B5899F18AD6C56CE08FE532B /* RoomSummaryProviderMock.swift in Sources */, AA050DF4AEE54A641BA7CA22 /* RoomSummaryProviderProtocol.swift in Sources */, + 708FC3184CCED825F0A36273 /* RoomThreadListServiceProxy.swift in Sources */, + 38E8F5CC90DC0D716B799DE1 /* RoomThreadListServiceProxyMock.swift in Sources */, + 12F7172C93873638B23CD6AD /* RoomThreadListServiceProxyProtocol.swift in Sources */, 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */, 13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */, 70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */, diff --git a/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme b/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme index eb510f50c..b50acdfed 100644 --- a/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme +++ b/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme @@ -4,8 +4,7 @@ version = "1.7"> + buildImplicitDependencies = "YES"> - - - - - - - - + + + + - - - - diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index f467f24e8..791bb6132 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -6930,6 +6930,70 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { return threadTimelineEventIDReturnValue } } + //MARK: - threadListService + + var threadListServiceUnderlyingCallsCount = 0 + var threadListServiceCallsCount: Int { + get { + if Thread.isMainThread { + return threadListServiceUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = threadListServiceUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + threadListServiceUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + threadListServiceUnderlyingCallsCount = newValue + } + } + } + } + var threadListServiceCalled: Bool { + return threadListServiceCallsCount > 0 + } + + var threadListServiceUnderlyingReturnValue: RoomThreadListServiceProxyProtocol! + var threadListServiceReturnValue: RoomThreadListServiceProxyProtocol! { + get { + if Thread.isMainThread { + return threadListServiceUnderlyingReturnValue + } else { + var returnValue: RoomThreadListServiceProxyProtocol? = nil + DispatchQueue.main.sync { + returnValue = threadListServiceUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + threadListServiceUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + threadListServiceUnderlyingReturnValue = newValue + } + } + } + } + var threadListServiceClosure: (() async -> RoomThreadListServiceProxyProtocol)? + + func threadListService() async -> RoomThreadListServiceProxyProtocol { + threadListServiceCallsCount += 1 + if let threadListServiceClosure = threadListServiceClosure { + return await threadListServiceClosure() + } else { + return threadListServiceReturnValue + } + } //MARK: - loadOrFetchEventDetails var loadOrFetchEventDetailsForUnderlyingCallsCount = 0 @@ -15957,6 +16021,83 @@ class RoomSummaryProviderMock: RoomSummaryProviderProtocol, @unchecked Sendable setRoomListClosure?(roomList) } } +class RoomThreadListServiceProxyMock: RoomThreadListServiceProxyProtocol, @unchecked Sendable { + var itemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never> { + get { return underlyingItemsPublisher } + set(value) { underlyingItemsPublisher = value } + } + var underlyingItemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never>! + var paginationStatePublisher: CurrentValuePublisher { + get { return underlyingPaginationStatePublisher } + set(value) { underlyingPaginationStatePublisher = value } + } + var underlyingPaginationStatePublisher: CurrentValuePublisher! + + //MARK: - paginate + + var paginateUnderlyingCallsCount = 0 + var paginateCallsCount: Int { + get { + if Thread.isMainThread { + return paginateUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = paginateUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + paginateUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + paginateUnderlyingCallsCount = newValue + } + } + } + } + var paginateCalled: Bool { + return paginateCallsCount > 0 + } + + var paginateUnderlyingReturnValue: Result! + var paginateReturnValue: Result! { + get { + if Thread.isMainThread { + return paginateUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = paginateUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + paginateUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + paginateUnderlyingReturnValue = newValue + } + } + } + } + var paginateClosure: (() async -> Result)? + + func paginate() async -> Result { + paginateCallsCount += 1 + if let paginateClosure = paginateClosure { + return await paginateClosure() + } else { + return paginateReturnValue + } + } +} class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sendable { var recoveryState: CurrentValuePublisher { get { return underlyingRecoveryState } diff --git a/ElementX/Sources/Mocks/RoomThreadListServiceProxyMock.swift b/ElementX/Sources/Mocks/RoomThreadListServiceProxyMock.swift new file mode 100644 index 000000000..d96183526 --- /dev/null +++ b/ElementX/Sources/Mocks/RoomThreadListServiceProxyMock.swift @@ -0,0 +1,43 @@ +// +// Copyright 2026 Element Creations 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 + +struct RoomThreadListProxyMockConfiguration { + var items: [RoomThreadListItem] = .mocks +} + +extension RoomThreadListServiceProxyMock { + convenience init(_ configuration: RoomThreadListProxyMockConfiguration) { + self.init() + + itemsPublisher = CurrentValueSubject(configuration.items).asCurrentValuePublisher() + + paginateReturnValue = .success(()) + } +} + +extension Array where Element == RoomThreadListItem { + static let mocks: [RoomThreadListItem] = [ + .init(id: "1", + rootMessageDetails: .init(sender: .init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: .mockMXCUserAvatar), + timestamp: .distantPast, + message: .init("Ping")), + latestMessageDetails: .init(sender: .init(id: "@bob:matrix.org"), + timestamp: .distantFuture, + message: .init("Pong")), + numberOfReplies: 5), + .init(id: "2", + rootMessageDetails: .init(sender: .init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: .mockMXCUserAvatar), + timestamp: .distantPast, + message: .init("Can we schedule a meeting for next week?")), + latestMessageDetails: .init(sender: .init(id: "@bob:matrix.org"), + timestamp: .distantFuture, + message: .init("Looking forward to our next steps!")), + numberOfReplies: 10) + ] +} diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index 709672aa4..53a6059e7 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -197,6 +197,18 @@ extension SDKListener: KnockRequestsListener where T == [KnockRequest] { } } +extension SDKListener: ThreadListEntriesListener where T == [ThreadListUpdate] { + func onUpdate(diff: [ThreadListUpdate]) { + onUpdateClosure(diff) + } +} + +extension SDKListener: ThreadListPaginationStateListener where T == ThreadListPaginationState { + func onUpdate(state: ThreadListPaginationState) { + onUpdateClosure(state) + } +} + // MARK: TimelineProxy extension SDKListener: PaginationStatusListener where T == PaginationStatus { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 27a8c529d..96937f77a 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -64,6 +64,8 @@ class ClientProxy: ClientProxyProtocol { let spaceService: SpaceServiceProxyProtocol + let eventStringBuilder: RoomEventStringBuilder + private static var roomCreationPowerLevelOverrides: PowerLevels { .init(usersDefault: nil, eventsDefault: nil, @@ -216,6 +218,7 @@ class ClientProxy: ClientProxyProtocol { roomSummaryProvider = configuredAppService.roomSummaryProvider alternateRoomSummaryProvider = configuredAppService.alternateRoomSummaryProvider staticRoomSummaryProvider = configuredAppService.staticRoomSummaryProvider + eventStringBuilder = configuredAppService.eventStringBuilder syncServiceStateUpdateTaskHandle = createSyncServiceStateObserver(syncService) roomListStateUpdateTaskHandle = createRoomListServiceObserver(roomListService) @@ -1169,7 +1172,8 @@ class ClientProxy: ClientProxyProtocol { let roomProxy = try await JoinedRoomProxy(roomListService: roomListService, room: room, appSettings: appSettings, - analyticsService: analyticsService) + analyticsService: analyticsService, + eventStringBuilder: eventStringBuilder) return .joined(roomProxy) case .left: @@ -1318,6 +1322,7 @@ private struct ClientProxyServices { let roomSummaryProvider: RoomSummaryProviderProtocol let alternateRoomSummaryProvider: RoomSummaryProviderProtocol let staticRoomSummaryProvider: StaticRoomSummaryProviderProtocol + let eventStringBuilder: RoomEventStringBuilder init(client: ClientProtocol, actionsSubject: PassthroughSubject, @@ -1334,10 +1339,11 @@ private struct ClientProxyServices { let roomMessageEventStringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(cacheKey: "roomList", mentionBuilder: PlainMentionBuilder()), style: .senderPrefixed) - let eventStringBuilder = try RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: client.userId(), shouldDisambiguateDisplayNames: false), - messageEventStringBuilder: roomMessageEventStringBuilder, - shouldDisambiguateDisplayNames: false, - shouldPrefixSenderName: true) + + eventStringBuilder = try RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: client.userId(), shouldDisambiguateDisplayNames: false), + messageEventStringBuilder: roomMessageEventStringBuilder, + shouldDisambiguateDisplayNames: false, + shouldPrefixSenderName: true) roomSummaryProvider = RoomSummaryProvider(roomListService: roomListService, eventStringBuilder: eventStringBuilder, diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 66d0a121e..bcdae9751 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -16,6 +16,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { private let room: RoomProtocol private let appSettings: AppSettings private let analyticsService: AnalyticsService + private let eventStringBuilder: RoomEventStringBuilder // periphery:ignore - required for instance retention in the rust codebase private var roomInfoObservationToken: TaskHandle? @@ -78,11 +79,13 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { init(roomListService: RoomListServiceProtocol, room: RoomProtocol, appSettings: AppSettings, - analyticsService: AnalyticsService) async throws { + analyticsService: AnalyticsService, + eventStringBuilder: RoomEventStringBuilder) async throws { self.roomListService = roomListService self.room = room self.appSettings = appSettings self.analyticsService = analyticsService + self.eventStringBuilder = eventStringBuilder infoSubject = try await .init(RoomInfoProxy(roomInfo: room.roomInfo())) @@ -200,6 +203,10 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } + func threadListService() async -> RoomThreadListServiceProxyProtocol { + await RoomThreadListProxy(threadListService: room.threadListService(), eventStringBuilder: eventStringBuilder) + } + func loadOrFetchEventDetails(for eventID: String) async -> Result { do { let event = try await room.loadOrFetchEvent(eventId: eventID) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 0dc5fe9f2..3d78f34b6 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -98,6 +98,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func threadTimeline(eventID: String) async -> Result + func threadListService() async -> RoomThreadListServiceProxyProtocol + func loadOrFetchEventDetails(for eventID: String) async -> Result func messageFilteredTimeline(focus: TimelineFocus, diff --git a/ElementX/Sources/Services/Room/RoomThreadListServiceProxy.swift b/ElementX/Sources/Services/Room/RoomThreadListServiceProxy.swift new file mode 100644 index 000000000..e42ef7097 --- /dev/null +++ b/ElementX/Sources/Services/Room/RoomThreadListServiceProxy.swift @@ -0,0 +1,101 @@ +// +// Copyright 2026 Element Creations 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 Foundation +import MatrixRustSDK + +class RoomThreadListProxy: RoomThreadListServiceProxyProtocol { + private let threadListService: ThreadListServiceProtocol + private let eventStringBuilder: RoomEventStringBuilder + + private var itemUpdatesHandle: TaskHandle? + private let itemsSubject = CurrentValueSubject<[RoomThreadListItem], Never>([]) + var itemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never> { + itemsSubject.asCurrentValuePublisher() + } + + private var paginationStateHandle: TaskHandle? + let paginationStatePublisher: CurrentValuePublisher + + init(threadListService: ThreadListServiceProtocol, eventStringBuilder: RoomEventStringBuilder, paginationToken: String? = nil) { + self.threadListService = threadListService + self.eventStringBuilder = eventStringBuilder + + let paginationStateSubject = CurrentValueSubject(.init(sdkState: threadListService.paginationState())) + paginationStatePublisher = paginationStateSubject.asCurrentValuePublisher() + + paginationStateHandle = threadListService.subscribeToPaginationStateUpdates(listener: SDKListener { state in + paginationStateSubject.send(.init(sdkState: state)) + }) + + updateItems() + + itemUpdatesHandle = self.threadListService.subscribeToItemsUpdates(listener: SDKListener { [weak self] _ in + self?.updateItems() + }) + } + + func paginate() async -> Result { + do { + try await threadListService.paginate() + return .success(()) + } catch { + MXLog.error("Failed paginating with error: \(error)") + return .failure(.sdkError(error)) + } + } + + // MARK: - Private + + private func updateItems() { + let items = threadListService.items().map { item in + guard let rootMessageDetails = buildMessageDetails(item.rootEvent) else { + fatalError("This should never failâ„¢") + } + + let latestMessageDetails = buildMessageDetails(item.latestEvent) + + return RoomThreadListItem(id: item.rootEvent.eventId, + rootMessageDetails: rootMessageDetails, + latestMessageDetails: latestMessageDetails, + numberOfReplies: UInt(item.numReplies)) + } + + itemsSubject.send(items) + } + + private func buildMessageDetails(_ threadListItemEvent: ThreadListItemEvent?) -> RoomThreadListItem.MessageDetails? { + guard let threadListItemEvent else { + return nil + } + + let sender = TimelineItemSender(senderID: threadListItemEvent.sender, senderProfile: threadListItemEvent.senderProfile) + let timestamp = Date(timeIntervalSince1970: TimeInterval(threadListItemEvent.timestamp / 1000)) + + let message: AttributedString? = if let content = threadListItemEvent.content { + eventStringBuilder.buildAttributedString(for: content, + sender: sender, + isOutgoing: threadListItemEvent.isOwn) + } else { + nil + } + + return .init(sender: sender, timestamp: timestamp, message: message) + } +} + +extension RoomThreadListPaginationState { + init(sdkState: MatrixRustSDK.ThreadListPaginationState) { + switch sdkState { + case .loading: + self = .loading + case .idle(let endReached): + self = .idle(endReached: endReached) + } + } +} diff --git a/ElementX/Sources/Services/Room/RoomThreadListServiceProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomThreadListServiceProxyProtocol.swift new file mode 100644 index 000000000..4982d6d25 --- /dev/null +++ b/ElementX/Sources/Services/Room/RoomThreadListServiceProxyProtocol.swift @@ -0,0 +1,38 @@ +// +// Copyright 2026 Element Creations 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 Foundation + +// sourcery: AutoMockable +protocol RoomThreadListServiceProxyProtocol { + var itemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never> { get } + + var paginationStatePublisher: CurrentValuePublisher { get } + + func paginate() async -> Result +} + +struct RoomThreadListItem: Identifiable { + struct MessageDetails { + let sender: TimelineItemSender + let timestamp: Date + let message: AttributedString? + } + + let id: String + + let rootMessageDetails: MessageDetails + let latestMessageDetails: MessageDetails? + + let numberOfReplies: UInt +} + +enum RoomThreadListPaginationState: Equatable { + case idle(endReached: Bool) + case loading +}