diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3714f2594d..c53ec59abd 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -497,6 +497,7 @@ 51263FC33CC961A14F5D6BCA /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; }; 5139F4BD5A5DF6F8D11A9BDE /* NotificationPermissionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D0BA44B1838E65B507B277 /* NotificationPermissionsScreen.swift */; }; 513AF15E0E84711B80D04B1B /* ReportRoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E9684DCE6B66BD0B5DF67 /* ReportRoomScreenViewModelTests.swift */; }; + 51A075328D68FB4AE65C43B5 /* SearchServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD6FFE581238BD30B53BA0A /* SearchServiceProxy.swift */; }; 51B3B19FA5F91B455C807BA7 /* RoomPollsHistoryScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E964AF2DFEB31E2B799999F /* RoomPollsHistoryScreenModels.swift */; }; 522269133E6F65F68482F4F4 /* RemotePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */; }; 52473A4D7B1FBD4CD1E770C8 /* MatrixEntityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */; }; @@ -1152,6 +1153,7 @@ BDFF0AEBF57B5B124062DAEF /* GeneratedAccessibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CEB4590CCF70F0E3C0B171 /* GeneratedAccessibilityTests.swift */; }; BE011C4473B9A8F12CBFE92A /* UserDetailsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F58D94C936A1B731D78DE1 /* UserDetailsEditScreenViewModelTests.swift */; }; BE8E5985771DF9137C6CE89A /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */; }; + BEB3D97EA51B25C87A37997E /* SearchServiceProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3E4640982CE98C6ADD7896 /* SearchServiceProxyProtocol.swift */; }; BEC6DFEA506085D3027E353C /* MediaEventsTimelineScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002399C6CB875C4EBB01CBC0 /* MediaEventsTimelineScreen.swift */; }; BED59052E5C5163D2B065CA6 /* EventTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */; }; BF523D9E12E3C4AABBA2F6CB /* SpaceHeaderTopicSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9F2E1FC5A0139571211CE8 /* SpaceHeaderTopicSheetView.swift */; }; @@ -2329,6 +2331,7 @@ 7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientBuilderHook.swift; sourceTree = ""; }; 7AC1E3FE9B59EA094867863E /* TimelineControllerFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineControllerFactoryProtocol.swift; sourceTree = ""; }; 7ACFFE7814849BB200CE0969 /* SpaceAddRoomsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreenModels.swift; sourceTree = ""; }; + 7AD6FFE581238BD30B53BA0A /* SearchServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceProxy.swift; sourceTree = ""; }; 7AE094FCB6387D268C436161 /* SecureBackupScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreenViewModel.swift; sourceTree = ""; }; 7AE75941583A033A9EDC9FE0 /* RoomChangePermissionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenViewModel.swift; sourceTree = ""; }; 7AE97AD18893E2D8120F559D /* RecoveryKeyScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryKeyScreenHook.swift; sourceTree = ""; }; @@ -2539,6 +2542,7 @@ 9EB9BA2F30EB8C33226D8FF1 /* UserSessionStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreMock.swift; sourceTree = ""; }; 9ECF11669EF253E98AA2977A /* CompletionSuggestionServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionServiceProtocol.swift; sourceTree = ""; }; 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; + 9F3E4640982CE98C6ADD7896 /* SearchServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceProxyProtocol.swift; sourceTree = ""; }; 9F40FB0A43DAECEC27C73722 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/SAS.strings; sourceTree = ""; }; 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModifier.swift; sourceTree = ""; }; 9FD7E851E2BA8C5A8D284B2A /* BannedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxyMock.swift; sourceTree = ""; }; @@ -3334,6 +3338,7 @@ 599DFFE0805B08454E40D64A /* Polls */, 40E6246F03D1FE377BC5D963 /* Room */, 4FFDC8D1A752384B4C6EB0EB /* RoomDirectorySearch */, + BF73FE1790E737263F0F504A /* Search */, BDCEF7C3BF6D09F5611CFC8B /* SecureBackup */, 82D5AD3EAE3A5C1068A44A88 /* Session */, 5329E48968EB951235E83DAE /* SessionVerification */, @@ -6287,6 +6292,15 @@ path = KnockRequestsListScreen; sourceTree = ""; }; + BF73FE1790E737263F0F504A /* Search */ = { + isa = PBXGroup; + children = ( + 7AD6FFE581238BD30B53BA0A /* SearchServiceProxy.swift */, + 9F3E4640982CE98C6ADD7896 /* SearchServiceProxyProtocol.swift */, + ); + path = Search; + sourceTree = ""; + }; C0937E3B06A8F0E2DB7C8241 /* Other */ = { isa = PBXGroup; children = ( @@ -8930,6 +8944,8 @@ 3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */, 6B8F03A85E83755D98DF0443 /* SearchScreenViewModel.swift in Sources */, 54804A7D8298F7E8ACAFDD76 /* SearchScreenViewModelProtocol.swift in Sources */, + 51A075328D68FB4AE65C43B5 /* SearchServiceProxy.swift in Sources */, + BEB3D97EA51B25C87A37997E /* SearchServiceProxyProtocol.swift in Sources */, 22B380C579C148BA0BFB5952 /* Secrets.swift in Sources */, 339BC18777912E1989F2F17D /* Section.swift in Sources */, F833D5B5BE6707F961FA88DB /* SecureBackupController.swift in Sources */, @@ -10279,7 +10295,7 @@ repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 26.06.30; + version = 26.07.02; }; }; 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4095a46ad2..f4111e70a8 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1c7d307d80ee561100a0432a5ae13f345392a1175c7f6c17c2eb0da269382c57", + "originHash" : "61e1de2b41c093a3c8796903827114a1435f5608a839ee17ba60d376e8f804f7", "pins" : [ { "identity" : "compound-design-tokens", @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "7f2a0d969742c03eb5e60216fc0ed35a4a5c13d2", - "version" : "26.6.30" + "revision" : "a23667230820d711f1d6d4517d078c2fef36e731", + "version" : "26.7.2" } }, { diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 91af059f38..8ee776e7f1 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -1,5 +1,7 @@ "screen_home_tab_search" = "Search"; -"screen_search_empty_state_message" = "Search for rooms"; +"screen_search_empty_state_message" = "Search for chats and messages"; +"screen_search_tab_messages" = "Messages"; +"screen_search_tab_rooms" = "Rooms"; "screen_search_empty_state_title" = "Start searching..."; "screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term."; "soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages."; diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index e0f3f9d7f2..3248071a56 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -98,6 +98,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) { let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, + clientProxy: flowParameters.userSession.clientProxy, mediaProvider: flowParameters.userSession.mediaProvider)) let searchStackCoordinator = NavigationStackCoordinator() searchStackCoordinator.setRootCoordinator(searchCoordinator) @@ -222,20 +223,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } + // swiftlint:disable:next function_body_length private func setupObservers() { - searchScreenCoordinator?.actionsPublisher - .sink { [weak self] action in - guard let self else { return } - switch action { - case .presentRoom(let roomID): - handleAppRoute(.room(roomID: roomID, via: []), animated: true) - case .cancel: - // Return to the tab the user came from, but never back into search. - navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats - } - } - .store(in: &cancellables) - chatsTabFlowCoordinator.actionsPublisher .sink { [weak self] action in guard let self else { return } @@ -334,6 +323,23 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } .store(in: &cancellables) + + searchScreenCoordinator?.actionsPublisher + .sink { [weak self] action in + guard let self else { return } + switch action { + case .presentRoom(let roomID, let eventID): + if let eventID { + handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), animated: true) + } else { + handleAppRoute(.room(roomID: roomID, via: []), animated: true) + } + case .cancel: + // Return to the tab the user came from, but never back into search. + navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats + } + } + .store(in: &cancellables) } // MARK: - Onboarding diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index a24c4b413d..270470ff26 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -12,7 +12,7 @@ import Foundation internal nonisolated enum UntranslatedL10n { /// Search internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") } - /// Search for rooms + /// Search for chats and messages internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") } /// Start searching... internal static var screenSearchEmptyStateTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_title") } @@ -20,6 +20,10 @@ internal nonisolated enum UntranslatedL10n { internal static func screenSearchNoResultsMessage(_ p1: Any) -> String { return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1)) } + /// Messages + internal static var screenSearchTabMessages: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_messages") } + /// Rooms + internal static var screenSearchTabRooms: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_rooms") } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 59d628e075..a04ecf7d19 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2358,6 +2358,11 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { set(value) { underlyingSpaceService = value } } nonisolated(unsafe) var underlyingSpaceService: SpaceServiceProxyProtocol! + var searchService: SearchServiceProxyProtocol { + get { return underlyingSearchService } + set(value) { underlyingSearchService = value } + } + nonisolated(unsafe) var underlyingSearchService: SearchServiceProxyProtocol! var capabilities: HomeserverCapabilitiesProxyProtocol { get { return underlyingCapabilities } set(value) { underlyingCapabilities = value } @@ -10935,6 +10940,78 @@ nonisolated class RoomThreadListServiceProxyMock: RoomThreadListServiceProxyProt } } } +nonisolated class SearchServiceProxyMock: SearchServiceProxyProtocol, @unchecked Sendable { + var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { + get { return underlyingResultsPublisher } + set(value) { underlyingResultsPublisher = value } + } + nonisolated(unsafe) var underlyingResultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never>! + var paginationStatePublisher: CurrentValuePublisher { + get { return underlyingPaginationStatePublisher } + set(value) { underlyingPaginationStatePublisher = value } + } + nonisolated(unsafe) var underlyingPaginationStatePublisher: CurrentValuePublisher! + + //MARK: - setQuery + + private let setQueryCallsCountLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingCallsCount = 0 + var setQueryCallsCount: Int { + get { setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount } } + set { setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount = newValue } } + } + var setQueryCalled: Bool { + return setQueryCallsCount > 0 + } + private let setQueryReceivedQueryLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingReceivedQuery: String? + var setQueryReceivedQuery: String? { + get { setQueryReceivedQueryLock.withLock { setQueryUnderlyingReceivedQuery } } + set { setQueryReceivedQueryLock.withLock { setQueryUnderlyingReceivedQuery = newValue } } + } + private let setQueryReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingReceivedInvocations: [String] = [] + var setQueryReceivedInvocations: [String] { + get { setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations } } + set { setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations = newValue } } + } + + private let setQueryReturnValueLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingReturnValue: Result! + var setQueryReturnValue: Result! { + get { setQueryReturnValueLock.withLock { setQueryUnderlyingReturnValue } } + set { setQueryReturnValueLock.withLock { setQueryUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var setQueryClosure: ((String) async -> Result)? + + @concurrent func setQuery(_ query: String) async -> Result { + setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount += 1 } + setQueryReceivedQuery = query + setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations.append(query) } + if let setQueryClosure = setQueryClosure { + return await setQueryClosure(query) + } else { + return setQueryReturnValue + } + } + //MARK: - paginate + + private let paginateCallsCountLock = NSLock() + private nonisolated(unsafe) var paginateUnderlyingCallsCount = 0 + var paginateCallsCount: Int { + get { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount } } + set { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount = newValue } } + } + var paginateCalled: Bool { + return paginateCallsCount > 0 + } + nonisolated(unsafe) var paginateClosure: (() async -> Void)? + + @concurrent func paginate() async { + paginateCallsCountLock.withLock { paginateUnderlyingCallsCount += 1 } + await paginateClosure?() + } +} nonisolated class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sendable { var recoveryState: CurrentValuePublisher { get { return underlyingRecoveryState } diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 28ef538f77..676db16a35 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -87,6 +87,7 @@ enum UserAvatarSizeOnScreen { case threadSummary case map case classicAppAccount + case search var value: CGFloat { switch self { @@ -107,7 +108,7 @@ enum UserAvatarSizeOnScreen { case .roomDetails: 44 case .inviteUsers, .knockingUserList, .sessionVerification, - .settings, .threadList, .mediaPreviewDetails: + .settings, .threadList, .mediaPreviewDetails, .search: 52 case .roomChangeRoles: 56 @@ -130,7 +131,7 @@ enum RoomAvatarSizeOnScreen { case timeline case leaveSpace case messageForwarding - case globalSearch + case search case roomSelection case details case editRoomDetails @@ -150,13 +151,13 @@ enum RoomAvatarSizeOnScreen { 30 case .timeline, .leaveSpace, .roomDirectorySearch, .completionSuggestions, .authorizedSpaces, .createRoomSelectSpace, - .spaceFilters, .globalSearch: + .spaceFilters: 32 case .messageForwarding, .roomSelection, .spaceAddRooms: 36 case .chats, .spaces, .spaceSettings, - .spaceAddRoomsSelected: + .spaceAddRoomsSelected, .search: 52 case .joinRoom, .spaceHeader, .editSpaceDetails: 64 diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index 48f4644e39..2de4c87169 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -293,3 +293,17 @@ nonisolated extension SDKListener: RoomDirectorySearchEntriesListener where T == onUpdateClosure(roomEntriesUpdate) } } + +// MARK: SearchServiceProxy + +nonisolated extension SDKListener: SearchServiceResultsListener where T == [SearchServiceResultsUpdate] { + func onUpdate(updates: [SearchServiceResultsUpdate]) { + onUpdateClosure(updates) + } +} + +nonisolated extension SDKListener: SearchServicePaginationStateListener where T == MatrixRustSDK.SearchServicePaginationState { + func onUpdate(paginationState: MatrixRustSDK.SearchServicePaginationState) { + onUpdateClosure(paginationState) + } +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index be5311da47..12f5fc5d20 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -10,11 +10,12 @@ import SwiftUI struct SearchScreenCoordinatorParameters { let roomSummaryProvider: RoomSummaryProviderProtocol + let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol } enum SearchScreenCoordinatorAction { - case presentRoom(roomID: String) + case presentRoom(roomID: String, eventID: String?) case cancel } @@ -30,6 +31,7 @@ final class SearchScreenCoordinator: CoordinatorProtocol { init(parameters: SearchScreenCoordinatorParameters) { viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, + clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider) } @@ -37,8 +39,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol { viewModel.actionsPublisher.sink { [weak self] action in guard let self else { return } switch action { - case .presentRoom(let roomID): - actionsSubject.send(.presentRoom(roomID: roomID)) + case .presentRoom(let roomID, let eventID): + actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID)) case .cancel: actionsSubject.send(.cancel) } diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index 63a27d0f2d..df673ea812 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -8,12 +8,31 @@ import Foundation enum SearchScreenViewModelAction { - case presentRoom(roomID: String) + case presentRoom(roomID: String, eventID: String?) case cancel } +enum SearchScreenMode: CaseIterable, Identifiable { + case rooms + case messages + + var id: Self { + self + } + + var title: String { + switch self { + case .rooms: UntranslatedL10n.screenSearchTabRooms + case .messages: UntranslatedL10n.screenSearchTabMessages + } + } +} + struct SearchScreenViewState: BindableState { var rooms = [SearchScreenRoom]() + var messages = [SearchScreenMessage]() + var isLoadingRooms = false + var isLoadingMessages = false var bindings: SearchScreenViewStateBindings var isSearching: Bool { @@ -23,11 +42,13 @@ struct SearchScreenViewState: BindableState { struct SearchScreenViewStateBindings { var searchQuery = "" + var searchMode: SearchScreenMode = .rooms } enum SearchScreenViewAction { case appeared case selectRoom(roomID: String) + case selectMessage(roomID: String, eventID: String) case reachedTop case reachedBottom case cancel @@ -39,3 +60,99 @@ struct SearchScreenRoom: Identifiable, Equatable { let description: String let avatar: RoomAvatar } + +struct SearchScreenMessage: Identifiable, Equatable { + let id: String + let roomID: String + let roomName: String + let roomAvatar: RoomAvatar + let senderName: String + let content: TimelineEventContent + let timestamp: Date + + init(_ result: SearchServiceResult, roomSummary: RoomSummary?, isOutgoing: Bool) { + id = result.eventID + roomID = result.roomID + roomName = roomSummary?.name ?? result.roomID + roomAvatar = roomSummary?.avatar ?? .room(id: result.roomID, name: roomSummary?.name, avatarURL: nil) + senderName = isOutgoing ? L10n.commonYou : result.sender.disambiguatedDisplayName ?? result.sender.id + content = result.content + timestamp = result.timestamp + } + + var preview: AttributedString? { + guard let messageBody else { return nil } + return AttributedString("\(senderName): ") + messageBody + } + + private var messageBody: AttributedString? { + switch content { + case .message(let content): + switch content { + case .text(let content): + content.formattedBody ?? AttributedString(content.body) + case .notice(let content): + content.formattedBody ?? AttributedString(content.body) + case .emote(let content): + content.formattedBody ?? AttributedString(content.body) + case .audio, .file, .image, .video: + nil + case .voice: + AttributedString(L10n.commonVoiceMessage) + case .location: + AttributedString(L10n.commonSharedLocation) + } + case .poll(let question): + AttributedString(question) + case .liveLocation: + AttributedString(L10n.commonSharedLiveLocation) + case .redacted: + AttributedString(L10n.commonMessageRemoved) + } + } + + var mediaPreview: SearchScreenMediaPreview? { + guard case .message(let content) = content else { return nil } + switch content { + case .file(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.fileSize), + kind: .file) + case .audio(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.fileSize), + kind: .audio) + case .image(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.imageInfo.fileSize), + kind: .image(thumbnail: content.thumbnailInfo ?? content.imageInfo, blurhash: content.blurhash)) + case .video(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize), + kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash)) + case .text, .notice, .emote, .voice, .location: + return nil + } + } + + private func mediaDetails(filename: String, fileSize: UInt?) -> String { + var details = filename.validatedFileExtension.uppercased() + if let fileSize { + details += " (\(fileSize.formatted(.byteCount(style: .file))))" + } + return details + } +} + +struct SearchScreenMediaPreview: Equatable { + enum Kind: Equatable { + case file + case audio + case image(thumbnail: ImageInfoProxy?, blurhash: String?) + case video(thumbnail: ImageInfoProxy?, blurhash: String?) + } + + let title: String + let details: String + let kind: Kind +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index 52d219e867..f859b78e0b 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -13,7 +13,11 @@ typealias SearchScreenViewModelType = StateStoreViewModelV2? + private var loadingObservationTask: Task? + private var setQueryTask: Task? private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { @@ -21,11 +25,15 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } init(roomSummaryProvider: RoomSummaryProviderProtocol, + clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, - initialSearchQuery: String = "") { + initialSearchQuery: String = "", + initialSearchMode: SearchScreenMode = .rooms) { self.roomSummaryProvider = roomSummaryProvider + self.clientProxy = clientProxy + searchService = clientProxy.searchService - super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery)), + super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)), mediaProvider: mediaProvider) roomSummaryProvider.roomListPublisher @@ -35,18 +43,54 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } .store(in: &cancellables) + searchService.resultsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] results in + guard let self else { return } + // A previous query's fetch can complete after the field was cleared; drop its late results. + guard !state.bindings.searchQuery.isEmpty else { + state.messages = [] + return + } + state.messages = results.map { result in + SearchScreenMessage(result, + roomSummary: clientProxy.roomSummaryForIdentifier(result.roomID), + isOutgoing: result.sender.id == clientProxy.userID) + } + } + .store(in: &cancellables) + + searchService.paginationStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] paginationState in + self?.state.isLoadingMessages = paginationState == .loading + } + .store(in: &cancellables) + + let debouncedSearchQueryStream = context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates() searchQueryObservationTask = Task { [weak self] in - guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } - for await searchQuery in stream { + for await searchQuery in debouncedSearchQueryStream { self?.updateFilter(for: searchQuery) } } + // Flip the loading indicator on the moment the user starts typing, ahead of the debounced + // query above, so the empty state doesn't flash while the first search is still pending. + let searchQueryStream = context.observe(\.viewState.bindings.searchQuery).removeDuplicates() + loadingObservationTask = Task { [weak self] in + for await searchQuery in searchQueryStream { + self?.state.isLoadingRooms = !searchQuery.isEmpty + self?.state.isLoadingMessages = !searchQuery.isEmpty + } + } + updateRooms(with: roomSummaryProvider.roomListPublisher.value) } isolated deinit { searchQueryObservationTask?.cancel() + loadingObservationTask?.cancel() + setQueryTask?.cancel() } // MARK: - Public @@ -60,11 +104,20 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro // Re-apply ours on every appearance to keep the displayed results in sync with the query. updateFilter(for: state.bindings.searchQuery) case .selectRoom(let roomID): - actionsSubject.send(.presentRoom(roomID: roomID)) + actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil)) + case .selectMessage(let roomID, let eventID): + actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID)) case .reachedTop: - updateVisibleRange(edge: .top) + if state.bindings.searchMode == .rooms { + updateVisibleRange(edge: .top) + } case .reachedBottom: - updateVisibleRange(edge: .bottom) + switch state.bindings.searchMode { + case .rooms: + updateVisibleRange(edge: .bottom) + case .messages: + Task { await searchService.paginate() } + } case .cancel: actionsSubject.send(.cancel) } @@ -73,17 +126,24 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro // MARK: - Private private func updateFilter(for searchQuery: String) { + // Supersede any in-flight query so its results can't land after a newer one's. + setQueryTask?.cancel() + if searchQuery.isEmpty { roomSummaryProvider.setFilter(.excludeAll) + state.messages = [] } else { roomSummaryProvider.setFilter(.search(query: searchQuery)) + setQueryTask = Task { [weak self] in + await self?.searchService.setQuery(searchQuery) + } } } private func updateRooms(with summaries: [RoomSummary]) { + // The list has caught up with the current filter, so we're no longer waiting on results. + state.isLoadingRooms = false state.rooms = summaries.map { summary in - // Show the matrix identifier as the subtitle: the other member's user ID for DMs, - // otherwise the room's canonical alias. let identifier = if summary.isDirect { summary.heroes.first?.userID ?? summary.canonicalAlias } else { diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index b570e324a2..dfacbc0adc 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine import Compound import GameController import SwiftUI @@ -14,11 +15,20 @@ struct SearchScreen: View { @Bindable var context: SearchScreenViewModel.Context @FocusState private var isSearchFieldFocused: Bool - /// The room highlighted for hardware keyboard selection (arrow keys + return). - @State private var selectedRoomID: String? + /// The result highlighted for hardware keyboard selection (arrow keys + return). Holds the id of a + /// room or a message depending on the active tab. + @State private var selectedID: String? /// The selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise. @State private var isHardwareKeyboardConnected = false + /// The ids of the results in the active tab, in display order. + private var selectableIDs: [String] { + switch context.viewState.bindings.searchMode { + case .rooms: context.viewState.rooms.map(\.id) + case .messages: context.viewState.messages.map(\.id) + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { // Rendered as content rather than a navigation title so it stays visible while the search field is focused. @@ -30,21 +40,48 @@ struct SearchScreen: View { .padding(.horizontal, 16) .padding(.vertical, 8) - if context.viewState.rooms.isEmpty { - emptyState - } else { - roomList + Picker(L10n.actionSearch, selection: $context.searchMode) { + ForEach(SearchScreenMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 8) + + switch context.viewState.bindings.searchMode { + case .rooms: + if context.viewState.rooms.isEmpty { + if context.viewState.isLoadingRooms { + loadingState + } else { + emptyState + } + } else { + roomList + } + case .messages: + if context.viewState.messages.isEmpty { + if context.viewState.isLoadingMessages { + loadingState + } else { + emptyState + } + } else { + messagesList + } } } .frame(maxHeight: .infinity, alignment: .top) .background(Color.compound.bgCanvasDefault) - .searchable(text: $context.searchQuery, placement: .toolbarPrincipal) + .conditionalSearchable(searchQuery: $context.searchQuery) .searchFocused($isSearchFieldFocused) .autocorrectionDisabled(true) + .background(tabShortcuts) .onSubmit(of: .search) { // A software keyboard's submit/search button just dismisses; only a hardware return selects. - if isHardwareKeyboardConnected, let selectedRoomID { - context.send(viewAction: .selectRoom(roomID: selectedRoomID)) + if isHardwareKeyboardConnected, selectedID != nil { + selectCurrent() } else { isSearchFieldFocused = false } @@ -57,7 +94,7 @@ struct SearchScreen: View { .onAppear { context.send(viewAction: .appeared) isSearchFieldFocused = true - selectedRoomID = context.viewState.rooms.first?.id + selectedID = selectableIDs.first updateHardwareKeyboardConnected() } .onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in @@ -68,8 +105,12 @@ struct SearchScreen: View { } // Reset to the first result when the results change (e.g. a new query), keyed on the top result // so pagination and background updates that leave it untouched don't clobber the selection. - .onChange(of: context.viewState.rooms.first?.id) { - selectedRoomID = context.viewState.rooms.first?.id + .onChange(of: selectableIDs.first) { + selectedID = selectableIDs.first + } + // Reset the selection to the top when switching tabs. + .onChange(of: context.viewState.bindings.searchMode) { + selectedID = selectableIDs.first } } @@ -92,13 +133,19 @@ struct SearchScreen: View { } } + private var loadingState: some View { + ProgressView() + .frame(maxWidth: .infinity) + .padding(40) + } + private var roomList: some View { List { ForEach(context.viewState.rooms) { room in SearchScreenRoomCell(room: room, context: context, isLast: room == context.viewState.rooms.last, - isSelected: isHardwareKeyboardConnected && selectedRoomID == room.id) + isSelected: isHardwareKeyboardConnected && selectedID == room.id) .onAppear { if room == context.viewState.rooms.first { context.send(viewAction: .reachedTop) @@ -111,18 +158,54 @@ struct SearchScreen: View { .compoundList(.plain) } + private var messagesList: some View { + List { + ForEach(context.viewState.messages) { message in + SearchScreenMessageCell(message: message, + context: context, + isSelected: isHardwareKeyboardConnected && selectedID == message.id) + .onAppear { + if message == context.viewState.messages.last { + context.send(viewAction: .reachedBottom) + } + } + } + } + .compoundList(.plain) + } + + /// Hidden buttons that switch tabs via ⌘1/⌘2, the common shortcut for jumping to tab N. + private var tabShortcuts: some View { + ForEach(Array(SearchScreenMode.allCases.enumerated()), id: \.element) { index, mode in + Button("") { context.searchMode = mode } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + .hidden() + } + } + private func moveSelection(backwards: Bool) { - let rooms = context.viewState.rooms - guard let selectedRoomID, - let currentIndex = rooms.firstIndex(where: { $0.id == selectedRoomID }) + let ids = selectableIDs + guard let selectedID, + let currentIndex = ids.firstIndex(of: selectedID) else { - selectedRoomID = rooms.first?.id + selectedID = ids.first return } let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1 - guard rooms.indices.contains(nextIndex) else { return } - self.selectedRoomID = rooms[nextIndex].id + guard ids.indices.contains(nextIndex) else { return } + self.selectedID = ids[nextIndex] + } + + private func selectCurrent() { + guard let selectedID else { return } + switch context.viewState.bindings.searchMode { + case .rooms: + context.send(viewAction: .selectRoom(roomID: selectedID)) + case .messages: + guard let message = context.viewState.messages.first(where: { $0.id == selectedID }) else { return } + context.send(viewAction: .selectMessage(roomID: message.roomID, eventID: message.id)) + } } private func updateHardwareKeyboardConnected() { @@ -130,6 +213,17 @@ struct SearchScreen: View { } } +private extension View { + /// Searchable makes the preview contents randomly appear lower + @ViewBuilder func conditionalSearchable(searchQuery: Binding) -> some View { + if !ProcessInfo.isXcodePreview, !ProcessInfo.isRunningTests { + searchable(text: searchQuery, placement: .toolbarPrincipal) + } else { + self + } + } +} + private struct SearchScreenRoomCell: View { let room: SearchScreenRoom let context: SearchScreenViewModel.Context @@ -142,12 +236,12 @@ private struct SearchScreenRoomCell: View { Button { context.send(viewAction: .selectRoom(roomID: room.id)) } label: { - HStack(spacing: 12) { + HStack(spacing: 16) { avatar - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { Text(room.title) - .font(.compound.bodyLG) + .font(.compound.bodyLGSemibold) .foregroundStyle(.compound.textPrimary) .lineLimit(1) @@ -161,7 +255,7 @@ private struct SearchScreenRoomCell: View { .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.vertical, 12) } .buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected)) .listRowInsets(.init()) @@ -173,7 +267,7 @@ private struct SearchScreenRoomCell: View { private var avatar: some View { if dynamicTypeSize < .accessibility3 { RoomAvatarImage(avatar: room.avatar, - avatarSize: .room(on: .globalSearch), + avatarSize: .room(on: .search), mediaProvider: context.mediaProvider) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .accessibilityHidden(true) @@ -192,6 +286,138 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle { } } +private struct SearchScreenMessageCell: View { + let message: SearchScreenMessage + let context: SearchScreenViewModel.Context + let isSelected: Bool + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + Button { + context.send(viewAction: .selectMessage(roomID: message.roomID, eventID: message.id)) + } label: { + HStack(alignment: .top, spacing: 16) { + avatar + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(message.roomName) + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + .lineLimit(3) + + Spacer() + + Text(message.timestamp.formattedMinimal()) + .font(.compound.bodySM) + .foregroundStyle(.compound.textSecondary) + } + + if let mediaPreview = message.mediaPreview { + SearchScreenMediaPreviewView(preview: mediaPreview, mediaProvider: context.mediaProvider) + } else if let preview = message.preview { + Text(preview) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected)) + .listRowInsets(.init()) + .listRowSeparator(.hidden) + .rowDivider() + } + + @ViewBuilder + private var avatar: some View { + if dynamicTypeSize < .accessibility3 { + RoomAvatarImage(avatar: message.roomAvatar, + avatarSize: .room(on: .search), + mediaProvider: context.mediaProvider) + .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) + .accessibilityHidden(true) + } + } +} + +private struct SearchScreenMediaPreviewView: View { + let preview: SearchScreenMediaPreview + let mediaProvider: MediaProviderProtocol? + + var body: some View { + HStack(spacing: 8) { + media + .scaledFrame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 0) { + Text(preview.title) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + .lineLimit(1) + Text(preview.details) + .font(.compound.bodySM) + .foregroundStyle(.compound.textSecondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.compound.bgSubtleSecondary, in: RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder + private var media: some View { + switch preview.kind { + case .file: + icon(\.attachment) + case .audio: + icon(\.audio) + case .image(let thumbnail, let blurhash): + thumbnailView(thumbnail, blurhash: blurhash, isVideo: false) + case .video(let thumbnail, let blurhash): + thumbnailView(thumbnail, blurhash: blurhash, isVideo: true) + } + } + + private func icon(_ icon: KeyPath) -> some View { + CompoundIcon(icon) + .foregroundStyle(.compound.iconPrimary) + .scaledFrame(width: 36, height: 36) + .background(.compound.iconOnSolidPrimary, + in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + + private func thumbnailView(_ thumbnail: ImageInfoProxy?, blurhash: String?, isVideo: Bool) -> some View { + Color.compound.bgSubtlePrimary // Let the image aspect fill in place + .overlay { + if let thumbnail { + LoadableImage(mediaSource: thumbnail.source, + blurhash: blurhash, + size: thumbnail.size, + mediaProvider: mediaProvider) { + Color.compound.bgSubtlePrimary + } + .mediaGalleryTimelineAspectRatio(imageInfo: thumbnail) + } + } + .overlay { + if isVideo { + CompoundIcon(\.playSolid, size: .small, relativeTo: .body) + .foregroundStyle(.compound.iconOnSolidPrimary) + } + } + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } +} + private extension View { /// Forwards hardware up/down arrow and escape presses from the system search field to the given closures. /// @@ -242,13 +468,26 @@ private final class KeyNavigatingSearchTextField: UISearchTextField { struct SearchScreen_Previews: PreviewProvider, TestablePreview { static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init())) - static let loadedViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - mediaProvider: MediaProviderMock(.init()), - initialSearchQuery: "Foundation") static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "John Doe") + static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation") + static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation", + initialSearchMode: .messages) + static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation", + initialSearchMode: .messages) static var previews: some View { ElementNavigationStack { @@ -262,8 +501,109 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview { .previewDisplayName("No results") ElementNavigationStack { - SearchScreen(context: loadedViewModel.context) + SearchScreen(context: roomsViewModel.context) } - .previewDisplayName("Loaded") + .previewDisplayName("Rooms") + + ElementNavigationStack { + SearchScreen(context: messagesViewModel.context) + } + .previewDisplayName("Messages") + + ElementNavigationStack { + SearchScreen(context: loadingMessagesViewModel.context) + } + .previewDisplayName("Loading messages") + } + + private static func makeSearchService(results: [SearchServiceResult] = [], paginationState: SearchServicePaginationState = .idle(endReached: true)) -> SearchServiceProxyMock { + let mock = SearchServiceProxyMock() + mock.underlyingResultsPublisher = CurrentValueSubject<[SearchServiceResult], Never>(results).asCurrentValuePublisher() + mock.underlyingPaginationStatePublisher = CurrentValueSubject(paginationState).asCurrentValuePublisher() + mock.setQueryReturnValue = .success(()) + return mock + } + + private static func makeClientProxy(searchService: SearchServiceProxyMock = makeSearchService()) -> ClientProxyMock { + let mock = ClientProxyMock(.init(userID: "@alice:matrix.org")) + mock.searchService = searchService + let names: [String: String] = ["!room1:matrix.org": "Alice", "!room2:matrix.org": "Bob", "!room3:matrix.org": "Coline", + "!room4:matrix.org": "Bob", "!room5:matrix.org": "Office", "!room6:matrix.org": "Data analytics", + "!room7:matrix.org": "Alice", "!room8:matrix.org": "Bob", "!room9:matrix.org": "Coline"] + mock.roomSummaryForIdentifierClosure = { id in .mock(id: id, name: names[id] ?? id) } + return mock + } +} + +private extension [SearchServiceResult] { + static var mockResults: [SearchServiceResult] { + [ + SearchServiceResult(roomID: "!room1:matrix.org", + eventID: "$1", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + content: .message(.text(.init(body: "Have you read the Foundation series?"))), + timestamp: .now), + SearchServiceResult(roomID: "!room2:matrix.org", + eventID: "$2", + sender: .init(id: "@bob:matrix.org", displayName: "Bob"), + content: .message(.text(.init(body: "The Second Foundation was hidden at the other end of the galaxy."))), + timestamp: .now), + SearchServiceResult(roomID: "!room3:matrix.org", + eventID: "$3", + sender: .init(id: "@coline:matrix.org", displayName: "Coline"), + content: .message(.file(.init(filename: "Foundation.pdf", + caption: nil, + source: nil, + fileSize: 4 * 1024 * 1024, + thumbnailSource: nil, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room4:matrix.org", + eventID: "$4", + sender: .init(id: "@bob:matrix.org", displayName: "Bob"), + content: .message(.audio(.init(filename: "Foundation.mp3", + caption: nil, + duration: 42, + waveform: nil, + source: nil, + fileSize: 4 * 1024 * 1024, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room5:matrix.org", + eventID: "$5", + sender: .init(id: "@office:matrix.org", displayName: "Office"), + content: .message(.image(.init(filename: "Foundation.jpg", + caption: nil, + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail, + blurhash: nil, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room6:matrix.org", + eventID: "$6", + sender: .init(id: "@data:matrix.org", displayName: "Data analytics"), + content: .message(.video(.init(filename: "Foundation.mp4", + caption: nil, + videoInfo: .mockVideo, + thumbnailInfo: .mockThumbnail, + blurhash: nil, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room7:matrix.org", + eventID: "$7", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + content: .poll(question: "What's your favourite Foundation book?"), + timestamp: .now), + SearchServiceResult(roomID: "!room8:matrix.org", + eventID: "$8", + sender: .init(id: "@bob:matrix.org", displayName: "Bob"), + content: .liveLocation, + timestamp: .now), + SearchServiceResult(roomID: "!room9:matrix.org", + eventID: "$9", + sender: .init(id: "@coline:matrix.org", displayName: "Coline"), + content: .redacted, + timestamp: .now) + ] } } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 41b0288e25..edf78f1086 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -66,6 +66,7 @@ class ClientProxy: ClientProxyProtocol { private(set) var sessionVerificationController: SessionVerificationControllerProxyProtocol? let spaceService: SpaceServiceProxyProtocol + let searchService: SearchServiceProxyProtocol let capabilities: HomeserverCapabilitiesProxyProtocol @@ -228,6 +229,13 @@ class ClientProxy: ClientProxyProtocol { spaceService = await SpaceServiceProxy(spaceService: client.spaceService()) + let searchUserID = try client.userId() + searchService = SearchServiceProxy(searchService: client.searchService(), + timelineItemFactory: RoomTimelineItemFactory(userID: searchUserID, + attributedStringBuilder: AttributedStringBuilder(cacheKey: "search", + mentionBuilder: PlainMentionBuilder()), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: searchUserID))) + capabilities = HomeserverCapabilitiesProxy(underlyingCapabilities: client.homeserverCapabilities()) let configuredAppService = try await ClientProxyServices(client: client, diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index f2023f2d58..8c33722ec0 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -155,6 +155,8 @@ protocol ClientProxyProtocol: AnyObject { var spaceService: SpaceServiceProxyProtocol { get } + var searchService: SearchServiceProxyProtocol { get } + var capabilities: HomeserverCapabilitiesProxyProtocol { get } var isReportRoomSupported: Bool { get async } diff --git a/ElementX/Sources/Services/Search/SearchServiceProxy.swift b/ElementX/Sources/Services/Search/SearchServiceProxy.swift new file mode 100644 index 0000000000..9aa17448ff --- /dev/null +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -0,0 +1,144 @@ +// Copyright 2025 Element Creations Ltd. +// Copyright 2025 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 Foundation +import MatrixRustSDK + +class SearchServiceProxy: SearchServiceProxyProtocol { + private let searchService: SearchServiceProtocol + private let timelineItemFactory: RoomTimelineItemFactoryProtocol + + // periphery:ignore - required for instance retention in the rust codebase + private var resultsHandle: TaskHandle? + + private let resultsSubject = CurrentValueSubject<[SearchServiceResult], Never>([]) + var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { + resultsSubject.asCurrentValuePublisher() + } + + // periphery:ignore - required for instance retention in the rust codebase + private var paginationStateHandle: TaskHandle? + + private let paginationStateSubject: CurrentValueSubject + var paginationStatePublisher: CurrentValuePublisher { + paginationStateSubject.asCurrentValuePublisher() + } + + init(searchService: SearchServiceProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) { + self.searchService = searchService + self.timelineItemFactory = timelineItemFactory + + paginationStateSubject = CurrentValueSubject(.init(sdkState: searchService.paginationState())) + + paginationStateHandle = searchService.subscribeToPaginationStateUpdates(listener: SDKListener.onMainActor { [weak self] state in + self?.paginationStateSubject.send(.init(sdkState: state)) + }) + } + + func setQuery(_ query: String) async -> Result { + if resultsHandle == nil { + resultsHandle = await searchService.subscribeToResults(listener: SDKListener.onMainActor { [weak self] updates in + self?.handleResultUpdates(updates) + }) + } + + do { + try await searchService.setQuery(query: query) + return .success(()) + } catch { + MXLog.error("Failed to set search query: \(error)") + return .failure(.sdkError(error)) + } + } + + func paginate() async { + do { + try await searchService.paginate() + } catch { + MXLog.error("Failed to paginate search results: \(error)") + } + } + + // MARK: - Private + + private func handleResultUpdates(_ updates: [SearchServiceResultsUpdate]) { + var results = resultsSubject.value + + for update in updates { + switch update { + case .append(let values): + results.append(contentsOf: values.compactMap(makeResult)) + case .clear: + results.removeAll() + case .pushFront(let value): + if let result = makeResult(value) { + results.insert(result, at: 0) + } + case .pushBack(let value): + if let result = makeResult(value) { + results.append(result) + } + case .popFront: + results.removeFirst() + case .popBack: + results.removeLast() + case .insert(let index, let value): + if let result = makeResult(value) { + results.insert(result, at: Int(index)) + } + case .set(let index, let value): + if let result = makeResult(value) { + results[Int(index)] = result + } + case .remove(let index): + results.remove(at: Int(index)) + case .truncate(let length): + results.removeSubrange(Int(length).. SearchServiceResult? { + switch searchResult { + case .message(let roomID, let result): + let sender = TimelineItemSender(senderID: result.sender, senderProfile: result.senderProfile) + + let content: TimelineEventContent + switch result.content { + case .msgLike(let msgLike): + switch msgLike.kind { + case .message(let msg): + content = .message(timelineItemFactory.buildMessageTimelineItemContent(messageType: msg.msgType, + senderID: result.sender, + senderDisplayName: sender.displayName)) + case .poll(let question, _, _, _, _, _, _): + content = .poll(question: question) + case .sticker(let body, _, _): + content = .message(.text(.init(body: body))) + case .redacted: + content = .redacted + case .liveLocation: + content = .liveLocation + default: + return nil + } + default: + return nil + } + + return SearchServiceResult(roomID: roomID, + eventID: result.eventId, + sender: sender, + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(result.timestamp) / 1000)) + } + } +} diff --git a/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift new file mode 100644 index 0000000000..4378d8deaf --- /dev/null +++ b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift @@ -0,0 +1,49 @@ +// Copyright 2025 Element Creations Ltd. +// Copyright 2025 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 Foundation +import MatrixRustSDK + +enum SearchServiceProxyError: Error { + case sdkError(Error) +} + +// sourcery: AutoMockable +protocol SearchServiceProxyProtocol { + var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { get } + + var paginationStatePublisher: CurrentValuePublisher { get } + + func setQuery(_ query: String) async -> Result + + /// Loads the next page of results. No-ops if a page is already loading or the end has been reached. + func paginate() async +} + +struct SearchServiceResult { + let roomID: String + let eventID: String + let sender: TimelineItemSender + let content: TimelineEventContent + let timestamp: Date +} + +enum SearchServicePaginationState: Equatable { + case idle(endReached: Bool) + case loading +} + +extension SearchServicePaginationState { + init(sdkState: MatrixRustSDK.SearchServicePaginationState) { + switch sdkState { + case .loading: + self = .loading + case .idle(let endReached): + self = .idle(endReached: endReached) + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 91e7f04003..85a7cda2dd 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -25,6 +25,8 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { self.stateEventStringBuilder = stateEventStringBuilder } + // MARK: - Public + func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol? { let isOutgoing = eventItemProxy.isOwn @@ -74,6 +76,83 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } } + func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply { + let isThreaded = details.event().isThreaded + switch details.event() { + case .unavailable: + return .init(details: .notLoaded(eventID: details.eventId()), isThreaded: isThreaded) + case .pending: + return .init(details: .loading(eventID: details.eventId()), isThreaded: isThreaded) + case let .ready(timelineItem, senderID, senderProfile, _, _): + let sender = TimelineItemSender(senderID: senderID, senderProfile: senderProfile) + + let replyContent: TimelineEventContent + + switch timelineItem { + case .msgLike(let messageLikeContent): + switch messageLikeContent.kind { + case .message(let messageContent): + let replyContent = buildMessageTimelineItemContent(messageType: messageContent.msgType, + senderID: sender.id, + senderDisplayName: sender.displayName) + return .init(details: .loaded(sender: sender, + eventID: details.eventId(), + eventContent: .message(replyContent)), + isThreaded: isThreaded) + case .poll(let question, _, _, _, _, _, _): + replyContent = .poll(question: question) + case .sticker(let body, _, _): + replyContent = .message(.text(.init(body: body))) + case .redacted: + replyContent = .redacted + case .liveLocation: + replyContent = .liveLocation + default: + replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) + } + default: + replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) + } + + return .init(details: .loaded(sender: sender, eventID: details.eventId(), eventContent: replyContent), isThreaded: isThreaded) + case let .error(message): + return .init(details: .error(eventID: details.eventId(), message: message), isThreaded: isThreaded) + } + } + + func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType { + switch messageType { + case .audio(let content): + if content.voice != nil { + .voice(buildAudioTimelineItemContent(content)) + } else { + .audio(buildAudioTimelineItemContent(content)) + } + case .emote(let content): + .emote(buildEmoteTimelineItemContent(senderDisplayName: senderDisplayName, senderID: senderID, messageContent: content)) + case .file(let content): + .file(buildFileTimelineItemContent(content)) + case .image(let content): + .image(buildImageTimelineItemContent(content)) + case .notice(let content): + .notice(buildNoticeTimelineItemContent(content)) + case .text(let content): + .text(buildTextTimelineItemContent(content)) + case .video(let content): + .video(buildVideoTimelineItemContent(content)) + case .location(let content): + .location(buildLocationTimelineItemContent(content)) + case .gallery(let content): + .text(.init(body: content.body)) + case .other(_, let body): + .text(.init(body: body)) + case .none: + .text(.init(body: L10n.commonUnsupportedEvent)) + } + } + + // MARK: - Private + // MARK: - MsgLike Events private func buildMessageTimelineItem(_ eventItemProxy: EventTimelineItemProxy, @@ -848,83 +927,6 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { return buildTimelineItemReply(details).details } - - func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply { - let isThreaded = details.event().isThreaded - switch details.event() { - case .unavailable: - return .init(details: .notLoaded(eventID: details.eventId()), isThreaded: isThreaded) - case .pending: - return .init(details: .loading(eventID: details.eventId()), isThreaded: isThreaded) - case let .ready(timelineItem, senderID, senderProfile, _, _): - let sender = TimelineItemSender(senderID: senderID, senderProfile: senderProfile) - - let replyContent: TimelineEventContent - - switch timelineItem { - case .msgLike(let messageLikeContent): - switch messageLikeContent.kind { - case .message(let messageContent): - let replyContent = buildMessageTimelineItemContent(messageType: messageContent.msgType, - senderID: sender.id, - senderDisplayName: sender.displayName) - return .init(details: .loaded(sender: sender, - eventID: details.eventId(), - eventContent: .message(replyContent)), - isThreaded: isThreaded) - case .poll(let question, _, _, _, _, _, _): - replyContent = .poll(question: question) - case .sticker(let body, _, _): - replyContent = .message(.text(.init(body: body))) - case .redacted: - replyContent = .redacted - case .liveLocation: - replyContent = .liveLocation - default: - replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) - } - default: - replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) - } - - return .init(details: .loaded(sender: sender, eventID: details.eventId(), eventContent: replyContent), isThreaded: isThreaded) - case let .error(message): - return .init(details: .error(eventID: details.eventId(), message: message), isThreaded: isThreaded) - } - } - - // MARK: - Helpers - - private func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType { - switch messageType { - case .audio(let content): - if content.voice != nil { - .voice(buildAudioTimelineItemContent(content)) - } else { - .audio(buildAudioTimelineItemContent(content)) - } - case .emote(let content): - .emote(buildEmoteTimelineItemContent(senderDisplayName: senderDisplayName, senderID: senderID, messageContent: content)) - case .file(let content): - .file(buildFileTimelineItemContent(content)) - case .image(let content): - .image(buildImageTimelineItemContent(content)) - case .notice(let content): - .notice(buildNoticeTimelineItemContent(content)) - case .text(let content): - .text(buildTextTimelineItemContent(content)) - case .video(let content): - .video(buildVideoTimelineItemContent(content)) - case .location(let content): - .location(buildLocationTimelineItemContent(content)) - case .gallery(let content): - .text(.init(body: content.body)) - case .other(_, let body): - .text(.init(body: body)) - case .none: - .text(.init(body: L10n.commonUnsupportedEvent)) - } - } } private nonisolated extension EmbeddedEventDetails { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift index f13d56d9bd..1e07e65e70 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift @@ -12,4 +12,5 @@ import MatrixRustSDK nonisolated protocol RoomTimelineItemFactoryProtocol: Sendable { func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol? func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply + func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType } diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 2849a463da..33ba9cea8d 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -140,6 +140,8 @@ class UserSessionStore: UserSessionStoreProtocol { .sqliteStore(config: .init(dataPath: credentials.restorationToken.sessionDirectories.dataPath, cachePath: credentials.restorationToken.sessionDirectories.cachePath) .passphrase(passphrase: credentials.restorationToken.passphrase)) + .withSearchIndexStore(path: credentials.restorationToken.sessionDirectories.dataPath, + password: credentials.restorationToken.passphrase) .username(username: credentials.userID) .homeserverUrl(url: homeserverURL) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png index fd8c9a10a0..a5b339a1ff 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a93a6802d8a053abd390e9311921294dfbab18844eb17a63eaf4b8c623173a9 -size 85119 +oid sha256:8332e6211e8fdad7dd1623748771d2008c17c2d8193a3ffe957346c8d94f4086 +size 94641 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png index cd515fcab3..a6445b31ae 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72835c30b6f459759cbbae44aaba4aed0c39674c49879602c7a8a319323b9db1 -size 88205 +oid sha256:aa5e1fa38d7bbfadb55daf260435e11c9860ae1f63411dfb756cbcf6a50f70c5 +size 102168 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png index c92f889042..9d0713129a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25f1c159081bfcf7e1fb181bb6f1ee2f614a1ae3759052cc5dac3f7266b271ba -size 42662 +oid sha256:b5fa800dc1baf2e5e52b39cdeb460d0463bde64f3ab721124050cfc992f308cf +size 50335 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png index d1ff6de077..93d69800fb 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:513a8d3815582253be1eecf22a7726c7917c9e83e7465aa5e7b90ef2d7944c1e -size 49713 +oid sha256:98a12236fc3813000eec89080c4a5e6fb4414ab690750c0c9fbf6ddc79b5d7fb +size 61874 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-en-GB.png new file mode 100644 index 0000000000..0269dee21c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11753001e5dbeb4bf3d2b71991096ffacba68ac9e4b244763affcefca20567cb +size 94885 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-pseudo.png new file mode 100644 index 0000000000..7a646a095a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df702d4f0929233e211341424ff14f8ed6ff1f558f7dcbfdcc627338e0612c79 +size 103618 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-en-GB.png new file mode 100644 index 0000000000..4d6edbfd79 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:759a3218edd55b0d4505108523f6d620a5db73fbf5f3b46ceb4f348e2b890486 +size 52394 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-pseudo.png new file mode 100644 index 0000000000..b681524b58 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd7cdf5319f41f891a6577565db68863f002c1fec23f7be265e039d3a51aaa9d +size 62596 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-en-GB.png new file mode 100644 index 0000000000..0269dee21c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11753001e5dbeb4bf3d2b71991096ffacba68ac9e4b244763affcefca20567cb +size 94885 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-pseudo.png new file mode 100644 index 0000000000..7a646a095a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df702d4f0929233e211341424ff14f8ed6ff1f558f7dcbfdcc627338e0612c79 +size 103618 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-en-GB.png new file mode 100644 index 0000000000..4d6edbfd79 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:759a3218edd55b0d4505108523f6d620a5db73fbf5f3b46ceb4f348e2b890486 +size 52394 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-pseudo.png new file mode 100644 index 0000000000..b681524b58 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd7cdf5319f41f891a6577565db68863f002c1fec23f7be265e039d3a51aaa9d +size 62596 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png index dba50b36da..657fc130b1 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d6cd51861f45a9e393eb3194ee7224a094646ce4385f2aa8ecb1be404bbaa6d -size 87898 +oid sha256:2e2a5821c0b45c566cbf23b7fc4a20c89d944e3b7fa7d0e527976926fe162386 +size 94721 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png index 808e935469..da59867130 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c96db7daacd17e38fb6d0d74cc9de51f52b0c17fb2c02c2c162d8384ca1ab6d2 -size 94243 +oid sha256:a31e6c8f3c6c12244291dfbe8796d46e68f77b7f4601bd6af8ef248024ce84c8 +size 102826 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png index c789b0415d..46e831804f 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31369bff674e454cb3cacdcb7a33efe035c838e4d09b549ba07979597d74a744 -size 49368 +oid sha256:09248c6998a1e731ed447c927f2f8a03c3ed72083090f56a8d8864d2d51db579 +size 51932 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png index 977678ec94..817bca09c9 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cc94abaa65358e0cf82a0635b0aaa5f7858f0a765b3ddf0f8b647cba493cfe6 -size 58249 +oid sha256:1f28caa2bc1a7aef205404d2af3ffb348b389c9eb83169482fcc1350c6120bab +size 62489 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-en-GB.png new file mode 100644 index 0000000000..54d4ddaba7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4aa5c23728897a395066dab65511f747347ed866968d96191377a206c037938 +size 174905 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-pseudo.png new file mode 100644 index 0000000000..142797aa1a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b58acb1090b90d13fa175b3dd8bf4318bdb41b51dcbe26d328d7ab0c57be0f4 +size 177766 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-en-GB.png new file mode 100644 index 0000000000..dae6bd8de3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:795306316da27b79588f5e941988c9ec851d59aa23b8c0153ffa5401837dc9ec +size 123602 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-pseudo.png new file mode 100644 index 0000000000..acfae06c39 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ad0f4d5f683254c0e0cf59f103f462338c77b56616570ef77cd8e334a76243a +size 126165 diff --git a/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift b/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift index 584f1cd2cf..5753b9615f 100644 --- a/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift +++ b/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift @@ -6,6 +6,21 @@ import Foundation +open class BackupSecretsSDKMock: MatrixRustSDK.BackupSecrets, @unchecked Sendable { + public init() { + super.init(noHandle: .init()) + } + + public required init(unsafeFromHandle handle: UInt64) { + fatalError("init(unsafeFromHandle:) has not been implemented") + } + + fileprivate var handle: UInt64 { + get { return underlyingHandle } + set(value) { underlyingHandle = value } + } + fileprivate var underlyingHandle: UInt64! +} open class CheckCodeSenderSDKMock: MatrixRustSDK.CheckCodeSender, @unchecked Sendable { public init() { super.init(noHandle: .init()) @@ -4173,50 +4188,32 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable { } } - //MARK: - searchMessages + //MARK: - searchService - open var searchMessagesQueryFilterThrowableError: Error? - private let searchMessagesQueryFilterCallsCountLock = NSLock() - private var searchMessagesQueryFilterUnderlyingCallsCount = 0 - open var searchMessagesQueryFilterCallsCount: Int { - get { searchMessagesQueryFilterCallsCountLock.withLock { searchMessagesQueryFilterUnderlyingCallsCount } } - set { searchMessagesQueryFilterCallsCountLock.withLock { searchMessagesQueryFilterUnderlyingCallsCount = newValue } } - } - open var searchMessagesQueryFilterCalled: Bool { - return searchMessagesQueryFilterCallsCount > 0 + private let searchServiceCallsCountLock = NSLock() + private var searchServiceUnderlyingCallsCount = 0 + open var searchServiceCallsCount: Int { + get { searchServiceCallsCountLock.withLock { searchServiceUnderlyingCallsCount } } + set { searchServiceCallsCountLock.withLock { searchServiceUnderlyingCallsCount = newValue } } } - private let searchMessagesQueryFilterReceivedArgumentsLock = NSLock() - private var searchMessagesQueryFilterUnderlyingReceivedArguments: (query: String, filter: SearchRoomFilter)? - open var searchMessagesQueryFilterReceivedArguments: (query: String, filter: SearchRoomFilter)? { - get { searchMessagesQueryFilterReceivedArgumentsLock.withLock { searchMessagesQueryFilterUnderlyingReceivedArguments } } - set { searchMessagesQueryFilterReceivedArgumentsLock.withLock { searchMessagesQueryFilterUnderlyingReceivedArguments = newValue } } - } - private let searchMessagesQueryFilterReceivedInvocationsLock = NSLock() - private var searchMessagesQueryFilterUnderlyingReceivedInvocations: [(query: String, filter: SearchRoomFilter)] = [] - open var searchMessagesQueryFilterReceivedInvocations: [(query: String, filter: SearchRoomFilter)] { - get { searchMessagesQueryFilterReceivedInvocationsLock.withLock { searchMessagesQueryFilterUnderlyingReceivedInvocations } } - set { searchMessagesQueryFilterReceivedInvocationsLock.withLock { searchMessagesQueryFilterUnderlyingReceivedInvocations = newValue } } + open var searchServiceCalled: Bool { + return searchServiceCallsCount > 0 } - private let searchMessagesQueryFilterReturnValueLock = NSLock() - open var searchMessagesQueryFilterUnderlyingReturnValue: GlobalSearchIterator! - open var searchMessagesQueryFilterReturnValue: GlobalSearchIterator! { - get { searchMessagesQueryFilterReturnValueLock.withLock { searchMessagesQueryFilterUnderlyingReturnValue } } - set { searchMessagesQueryFilterReturnValueLock.withLock { searchMessagesQueryFilterUnderlyingReturnValue = newValue } } + private let searchServiceReturnValueLock = NSLock() + open var searchServiceUnderlyingReturnValue: SearchService! + open var searchServiceReturnValue: SearchService! { + get { searchServiceReturnValueLock.withLock { searchServiceUnderlyingReturnValue } } + set { searchServiceReturnValueLock.withLock { searchServiceUnderlyingReturnValue = newValue } } } - open var searchMessagesQueryFilterClosure: ((String, SearchRoomFilter) async throws -> GlobalSearchIterator)? + open var searchServiceClosure: (() -> SearchService)? - open override func searchMessages(query: String, filter: SearchRoomFilter) async throws -> GlobalSearchIterator { - if let error = searchMessagesQueryFilterThrowableError { - throw error - } - searchMessagesQueryFilterCallsCountLock.withLock { searchMessagesQueryFilterUnderlyingCallsCount += 1 } - searchMessagesQueryFilterReceivedArguments = (query: query, filter: filter) - searchMessagesQueryFilterReceivedInvocationsLock.withLock { searchMessagesQueryFilterUnderlyingReceivedInvocations.append((query: query, filter: filter)) } - if let searchMessagesQueryFilterClosure = searchMessagesQueryFilterClosure { - return try await searchMessagesQueryFilterClosure(query, filter) + open override func searchService() -> SearchService { + searchServiceCallsCountLock.withLock { searchServiceUnderlyingCallsCount += 1 } + if let searchServiceClosure = searchServiceClosure { + return searchServiceClosure() } else { - return searchMessagesQueryFilterReturnValue + return searchServiceReturnValue } } } @@ -5421,6 +5418,21 @@ open class ContentScannerSDKMock: MatrixRustSDK.ContentScanner, @unchecked Senda } } } +open class CrossSigningSecretsSDKMock: MatrixRustSDK.CrossSigningSecrets, @unchecked Sendable { + public init() { + super.init(noHandle: .init()) + } + + public required init(unsafeFromHandle handle: UInt64) { + fatalError("init(unsafeFromHandle:) has not been implemented") + } + + fileprivate var handle: UInt64 { + get { return underlyingHandle } + set(value) { underlyingHandle = value } + } + fileprivate var underlyingHandle: UInt64! +} open class EncryptionSDKMock: MatrixRustSDK.Encryption, @unchecked Sendable { public init() { super.init(noHandle: .init()) @@ -6222,54 +6234,6 @@ open class EncryptionSDKMock: MatrixRustSDK.Encryption, @unchecked Sendable { await waitForE2eeInitializationTasksClosure?() } } -open class GlobalSearchIteratorSDKMock: MatrixRustSDK.GlobalSearchIterator, @unchecked Sendable { - public init() { - super.init(noHandle: .init()) - } - - public required init(unsafeFromHandle handle: UInt64) { - fatalError("init(unsafeFromHandle:) has not been implemented") - } - - fileprivate var handle: UInt64 { - get { return underlyingHandle } - set(value) { underlyingHandle = value } - } - fileprivate var underlyingHandle: UInt64! - - //MARK: - nextEvents - - open var nextEventsThrowableError: Error? - private let nextEventsCallsCountLock = NSLock() - private var nextEventsUnderlyingCallsCount = 0 - open var nextEventsCallsCount: Int { - get { nextEventsCallsCountLock.withLock { nextEventsUnderlyingCallsCount } } - set { nextEventsCallsCountLock.withLock { nextEventsUnderlyingCallsCount = newValue } } - } - open var nextEventsCalled: Bool { - return nextEventsCallsCount > 0 - } - - private let nextEventsReturnValueLock = NSLock() - open var nextEventsUnderlyingReturnValue: [GlobalSearchResult]? - open var nextEventsReturnValue: [GlobalSearchResult]? { - get { nextEventsReturnValueLock.withLock { nextEventsUnderlyingReturnValue } } - set { nextEventsReturnValueLock.withLock { nextEventsUnderlyingReturnValue = newValue } } - } - open var nextEventsClosure: (() async throws -> [GlobalSearchResult]?)? - - open override func nextEvents() async throws -> [GlobalSearchResult]? { - if let error = nextEventsThrowableError { - throw error - } - nextEventsCallsCountLock.withLock { nextEventsUnderlyingCallsCount += 1 } - if let nextEventsClosure = nextEventsClosure { - return try await nextEventsClosure() - } else { - return nextEventsReturnValue - } - } -} open class GrantLoginWithQrCodeHandlerSDKMock: MatrixRustSDK.GrantLoginWithQrCodeHandler, @unchecked Sendable { public init() { super.init(noHandle: .init()) @@ -8689,6 +8653,21 @@ open class OAuthAuthorizationDataSDKMock: MatrixRustSDK.OAuthAuthorizationData, } } } +open class PrivateStringSDKMock: MatrixRustSDK.PrivateString, @unchecked Sendable { + public init() { + super.init(noHandle: .init()) + } + + public required init(unsafeFromHandle handle: UInt64) { + fatalError("init(unsafeFromHandle:) has not been implemented") + } + + fileprivate var handle: UInt64 { + get { return underlyingHandle } + set(value) { underlyingHandle = value } + } + fileprivate var underlyingHandle: UInt64! +} open class QrCodeDataSDKMock: MatrixRustSDK.QrCodeData, @unchecked Sendable { public init() { super.init(noHandle: .init()) @@ -12307,49 +12286,6 @@ open class RoomSDKMock: MatrixRustSDK.Room, @unchecked Sendable { withdrawVerificationAndResendUserIdsSendHandleReceivedInvocationsLock.withLock { withdrawVerificationAndResendUserIdsSendHandleUnderlyingReceivedInvocations.append((userIds: userIds, sendHandle: sendHandle)) } try await withdrawVerificationAndResendUserIdsSendHandleClosure?(userIds, sendHandle) } - - //MARK: - searchMessages - - private let searchMessagesQueryCallsCountLock = NSLock() - private var searchMessagesQueryUnderlyingCallsCount = 0 - open var searchMessagesQueryCallsCount: Int { - get { searchMessagesQueryCallsCountLock.withLock { searchMessagesQueryUnderlyingCallsCount } } - set { searchMessagesQueryCallsCountLock.withLock { searchMessagesQueryUnderlyingCallsCount = newValue } } - } - open var searchMessagesQueryCalled: Bool { - return searchMessagesQueryCallsCount > 0 - } - private let searchMessagesQueryReceivedQueryLock = NSLock() - private var searchMessagesQueryUnderlyingReceivedQuery: String? - open var searchMessagesQueryReceivedQuery: String? { - get { searchMessagesQueryReceivedQueryLock.withLock { searchMessagesQueryUnderlyingReceivedQuery } } - set { searchMessagesQueryReceivedQueryLock.withLock { searchMessagesQueryUnderlyingReceivedQuery = newValue } } - } - private let searchMessagesQueryReceivedInvocationsLock = NSLock() - private var searchMessagesQueryUnderlyingReceivedInvocations: [String] = [] - open var searchMessagesQueryReceivedInvocations: [String] { - get { searchMessagesQueryReceivedInvocationsLock.withLock { searchMessagesQueryUnderlyingReceivedInvocations } } - set { searchMessagesQueryReceivedInvocationsLock.withLock { searchMessagesQueryUnderlyingReceivedInvocations = newValue } } - } - - private let searchMessagesQueryReturnValueLock = NSLock() - open var searchMessagesQueryUnderlyingReturnValue: RoomSearchIterator! - open var searchMessagesQueryReturnValue: RoomSearchIterator! { - get { searchMessagesQueryReturnValueLock.withLock { searchMessagesQueryUnderlyingReturnValue } } - set { searchMessagesQueryReturnValueLock.withLock { searchMessagesQueryUnderlyingReturnValue = newValue } } - } - open var searchMessagesQueryClosure: ((String) -> RoomSearchIterator)? - - open override func searchMessages(query: String) -> RoomSearchIterator { - searchMessagesQueryCallsCountLock.withLock { searchMessagesQueryUnderlyingCallsCount += 1 } - searchMessagesQueryReceivedQuery = query - searchMessagesQueryReceivedInvocationsLock.withLock { searchMessagesQueryUnderlyingReceivedInvocations.append(query) } - if let searchMessagesQueryClosure = searchMessagesQueryClosure { - return searchMessagesQueryClosure(query) - } else { - return searchMessagesQueryReturnValue - } - } } open class RoomDirectorySearchSDKMock: MatrixRustSDK.RoomDirectorySearch, @unchecked Sendable { public init() { @@ -14174,7 +14110,7 @@ open class RoomPreviewSDKMock: MatrixRustSDK.RoomPreview, @unchecked Sendable { } } } -open class RoomSearchIteratorSDKMock: MatrixRustSDK.RoomSearchIterator, @unchecked Sendable { +open class SearchServiceSDKMock: MatrixRustSDK.SearchService, @unchecked Sendable { public init() { super.init(noHandle: .init()) } @@ -14189,39 +14125,194 @@ open class RoomSearchIteratorSDKMock: MatrixRustSDK.RoomSearchIterator, @uncheck } fileprivate var underlyingHandle: UInt64! - //MARK: - nextEvents + //MARK: - paginate + + open var paginateThrowableError: Error? + private let paginateCallsCountLock = NSLock() + private var paginateUnderlyingCallsCount = 0 + open var paginateCallsCount: Int { + get { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount } } + set { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount = newValue } } + } + open var paginateCalled: Bool { + return paginateCallsCount > 0 + } + open var paginateClosure: (() async throws -> Void)? + + open override func paginate() async throws { + if let error = paginateThrowableError { + throw error + } + paginateCallsCountLock.withLock { paginateUnderlyingCallsCount += 1 } + try await paginateClosure?() + } + + //MARK: - paginationState + + private let paginationStateCallsCountLock = NSLock() + private var paginationStateUnderlyingCallsCount = 0 + open var paginationStateCallsCount: Int { + get { paginationStateCallsCountLock.withLock { paginationStateUnderlyingCallsCount } } + set { paginationStateCallsCountLock.withLock { paginationStateUnderlyingCallsCount = newValue } } + } + open var paginationStateCalled: Bool { + return paginationStateCallsCount > 0 + } - open var nextEventsThrowableError: Error? - private let nextEventsCallsCountLock = NSLock() - private var nextEventsUnderlyingCallsCount = 0 - open var nextEventsCallsCount: Int { - get { nextEventsCallsCountLock.withLock { nextEventsUnderlyingCallsCount } } - set { nextEventsCallsCountLock.withLock { nextEventsUnderlyingCallsCount = newValue } } + private let paginationStateReturnValueLock = NSLock() + open var paginationStateUnderlyingReturnValue: SearchServicePaginationState! + open var paginationStateReturnValue: SearchServicePaginationState! { + get { paginationStateReturnValueLock.withLock { paginationStateUnderlyingReturnValue } } + set { paginationStateReturnValueLock.withLock { paginationStateUnderlyingReturnValue = newValue } } } - open var nextEventsCalled: Bool { - return nextEventsCallsCount > 0 + open var paginationStateClosure: (() -> SearchServicePaginationState)? + + open override func paginationState() -> SearchServicePaginationState { + paginationStateCallsCountLock.withLock { paginationStateUnderlyingCallsCount += 1 } + if let paginationStateClosure = paginationStateClosure { + return paginationStateClosure() + } else { + return paginationStateReturnValue + } } - private let nextEventsReturnValueLock = NSLock() - open var nextEventsUnderlyingReturnValue: [RoomSearchResult]? - open var nextEventsReturnValue: [RoomSearchResult]? { - get { nextEventsReturnValueLock.withLock { nextEventsUnderlyingReturnValue } } - set { nextEventsReturnValueLock.withLock { nextEventsUnderlyingReturnValue = newValue } } + //MARK: - setQuery + + open var setQueryQueryThrowableError: Error? + private let setQueryQueryCallsCountLock = NSLock() + private var setQueryQueryUnderlyingCallsCount = 0 + open var setQueryQueryCallsCount: Int { + get { setQueryQueryCallsCountLock.withLock { setQueryQueryUnderlyingCallsCount } } + set { setQueryQueryCallsCountLock.withLock { setQueryQueryUnderlyingCallsCount = newValue } } + } + open var setQueryQueryCalled: Bool { + return setQueryQueryCallsCount > 0 } - open var nextEventsClosure: (() async throws -> [RoomSearchResult]?)? + private let setQueryQueryReceivedQueryLock = NSLock() + private var setQueryQueryUnderlyingReceivedQuery: String? + open var setQueryQueryReceivedQuery: String? { + get { setQueryQueryReceivedQueryLock.withLock { setQueryQueryUnderlyingReceivedQuery } } + set { setQueryQueryReceivedQueryLock.withLock { setQueryQueryUnderlyingReceivedQuery = newValue } } + } + private let setQueryQueryReceivedInvocationsLock = NSLock() + private var setQueryQueryUnderlyingReceivedInvocations: [String] = [] + open var setQueryQueryReceivedInvocations: [String] { + get { setQueryQueryReceivedInvocationsLock.withLock { setQueryQueryUnderlyingReceivedInvocations } } + set { setQueryQueryReceivedInvocationsLock.withLock { setQueryQueryUnderlyingReceivedInvocations = newValue } } + } + open var setQueryQueryClosure: ((String) async throws -> Void)? - open override func nextEvents() async throws -> [RoomSearchResult]? { - if let error = nextEventsThrowableError { + open override func setQuery(query: String) async throws { + if let error = setQueryQueryThrowableError { throw error } - nextEventsCallsCountLock.withLock { nextEventsUnderlyingCallsCount += 1 } - if let nextEventsClosure = nextEventsClosure { - return try await nextEventsClosure() + setQueryQueryCallsCountLock.withLock { setQueryQueryUnderlyingCallsCount += 1 } + setQueryQueryReceivedQuery = query + setQueryQueryReceivedInvocationsLock.withLock { setQueryQueryUnderlyingReceivedInvocations.append(query) } + try await setQueryQueryClosure?(query) + } + + //MARK: - subscribeToPaginationStateUpdates + + private let subscribeToPaginationStateUpdatesListenerCallsCountLock = NSLock() + private var subscribeToPaginationStateUpdatesListenerUnderlyingCallsCount = 0 + open var subscribeToPaginationStateUpdatesListenerCallsCount: Int { + get { subscribeToPaginationStateUpdatesListenerCallsCountLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingCallsCount } } + set { subscribeToPaginationStateUpdatesListenerCallsCountLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingCallsCount = newValue } } + } + open var subscribeToPaginationStateUpdatesListenerCalled: Bool { + return subscribeToPaginationStateUpdatesListenerCallsCount > 0 + } + private let subscribeToPaginationStateUpdatesListenerReceivedListenerLock = NSLock() + private var subscribeToPaginationStateUpdatesListenerUnderlyingReceivedListener: SearchServicePaginationStateListener? + open var subscribeToPaginationStateUpdatesListenerReceivedListener: SearchServicePaginationStateListener? { + get { subscribeToPaginationStateUpdatesListenerReceivedListenerLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReceivedListener } } + set { subscribeToPaginationStateUpdatesListenerReceivedListenerLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReceivedListener = newValue } } + } + private let subscribeToPaginationStateUpdatesListenerReceivedInvocationsLock = NSLock() + private var subscribeToPaginationStateUpdatesListenerUnderlyingReceivedInvocations: [SearchServicePaginationStateListener] = [] + open var subscribeToPaginationStateUpdatesListenerReceivedInvocations: [SearchServicePaginationStateListener] { + get { subscribeToPaginationStateUpdatesListenerReceivedInvocationsLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReceivedInvocations } } + set { subscribeToPaginationStateUpdatesListenerReceivedInvocationsLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReceivedInvocations = newValue } } + } + + private let subscribeToPaginationStateUpdatesListenerReturnValueLock = NSLock() + open var subscribeToPaginationStateUpdatesListenerUnderlyingReturnValue: TaskHandle! + open var subscribeToPaginationStateUpdatesListenerReturnValue: TaskHandle! { + get { subscribeToPaginationStateUpdatesListenerReturnValueLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReturnValue } } + set { subscribeToPaginationStateUpdatesListenerReturnValueLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReturnValue = newValue } } + } + open var subscribeToPaginationStateUpdatesListenerClosure: ((SearchServicePaginationStateListener) -> TaskHandle)? + + open override func subscribeToPaginationStateUpdates(listener: SearchServicePaginationStateListener) -> TaskHandle { + subscribeToPaginationStateUpdatesListenerCallsCountLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingCallsCount += 1 } + subscribeToPaginationStateUpdatesListenerReceivedListener = listener + subscribeToPaginationStateUpdatesListenerReceivedInvocationsLock.withLock { subscribeToPaginationStateUpdatesListenerUnderlyingReceivedInvocations.append(listener) } + if let subscribeToPaginationStateUpdatesListenerClosure = subscribeToPaginationStateUpdatesListenerClosure { + return subscribeToPaginationStateUpdatesListenerClosure(listener) + } else { + return subscribeToPaginationStateUpdatesListenerReturnValue + } + } + + //MARK: - subscribeToResults + + private let subscribeToResultsListenerCallsCountLock = NSLock() + private var subscribeToResultsListenerUnderlyingCallsCount = 0 + open var subscribeToResultsListenerCallsCount: Int { + get { subscribeToResultsListenerCallsCountLock.withLock { subscribeToResultsListenerUnderlyingCallsCount } } + set { subscribeToResultsListenerCallsCountLock.withLock { subscribeToResultsListenerUnderlyingCallsCount = newValue } } + } + open var subscribeToResultsListenerCalled: Bool { + return subscribeToResultsListenerCallsCount > 0 + } + private let subscribeToResultsListenerReceivedListenerLock = NSLock() + private var subscribeToResultsListenerUnderlyingReceivedListener: SearchServiceResultsListener? + open var subscribeToResultsListenerReceivedListener: SearchServiceResultsListener? { + get { subscribeToResultsListenerReceivedListenerLock.withLock { subscribeToResultsListenerUnderlyingReceivedListener } } + set { subscribeToResultsListenerReceivedListenerLock.withLock { subscribeToResultsListenerUnderlyingReceivedListener = newValue } } + } + private let subscribeToResultsListenerReceivedInvocationsLock = NSLock() + private var subscribeToResultsListenerUnderlyingReceivedInvocations: [SearchServiceResultsListener] = [] + open var subscribeToResultsListenerReceivedInvocations: [SearchServiceResultsListener] { + get { subscribeToResultsListenerReceivedInvocationsLock.withLock { subscribeToResultsListenerUnderlyingReceivedInvocations } } + set { subscribeToResultsListenerReceivedInvocationsLock.withLock { subscribeToResultsListenerUnderlyingReceivedInvocations = newValue } } + } + + private let subscribeToResultsListenerReturnValueLock = NSLock() + open var subscribeToResultsListenerUnderlyingReturnValue: TaskHandle! + open var subscribeToResultsListenerReturnValue: TaskHandle! { + get { subscribeToResultsListenerReturnValueLock.withLock { subscribeToResultsListenerUnderlyingReturnValue } } + set { subscribeToResultsListenerReturnValueLock.withLock { subscribeToResultsListenerUnderlyingReturnValue = newValue } } + } + open var subscribeToResultsListenerClosure: ((SearchServiceResultsListener) async -> TaskHandle)? + + open override func subscribeToResults(listener: SearchServiceResultsListener) async -> TaskHandle { + subscribeToResultsListenerCallsCountLock.withLock { subscribeToResultsListenerUnderlyingCallsCount += 1 } + subscribeToResultsListenerReceivedListener = listener + subscribeToResultsListenerReceivedInvocationsLock.withLock { subscribeToResultsListenerUnderlyingReceivedInvocations.append(listener) } + if let subscribeToResultsListenerClosure = subscribeToResultsListenerClosure { + return await subscribeToResultsListenerClosure(listener) } else { - return nextEventsReturnValue + return subscribeToResultsListenerReturnValue } } } +open class SecretsBundleSDKMock: MatrixRustSDK.SecretsBundle, @unchecked Sendable { + public init() { + super.init(noHandle: .init()) + } + + public required init(unsafeFromHandle handle: UInt64) { + fatalError("init(unsafeFromHandle:) has not been implemented") + } + + fileprivate var handle: UInt64 { + get { return underlyingHandle } + set(value) { underlyingHandle = value } + } + fileprivate var underlyingHandle: UInt64! +} open class SecretsBundleWithUserIdSDKMock: MatrixRustSDK.SecretsBundleWithUserId, @unchecked Sendable { public init() { super.init(noHandle: .init()) diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index a862b9254f..12cb08f893 100644 --- a/UnitTests/Sources/SearchScreenViewModelTests.swift +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine @testable import ElementX import Testing @@ -16,7 +17,16 @@ struct SearchScreenViewModelTests { } init() { + let searchService = SearchServiceProxyMock() + searchService.underlyingResultsPublisher = CurrentValueSubject<[SearchServiceResult], Never>([]).asCurrentValuePublisher() + searchService.underlyingPaginationStatePublisher = CurrentValueSubject(.idle(endReached: true)).asCurrentValuePublisher() + searchService.setQueryReturnValue = .success(()) + + let clientProxy = ClientProxyMock(.init()) + clientProxy.searchService = searchService + viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + clientProxy: clientProxy, mediaProvider: MediaProviderMock(.init())) } @@ -31,7 +41,7 @@ struct SearchScreenViewModelTests { func roomSelection() async throws { let deferred = deferFulfillment(viewModel.actionsPublisher) { action in switch action { - case .presentRoom(let roomID): + case .presentRoom(let roomID, _): roomID == "2" case .cancel: false diff --git a/project.yml b/project.yml index 9df70f4493..8230a92504 100644 --- a/project.yml +++ b/project.yml @@ -74,7 +74,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/element-hq/matrix-rust-components-swift - exactVersion: 26.06.30 + exactVersion: 26.07.02 # path: ../matrix-rust-sdk Compound: path: compound-ios