From 9905031e618c54a92e87623c7574f81eec8ec9ad Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 2 Jul 2026 13:04:21 +0300 Subject: [PATCH 1/7] Expose a global search service from the client Wrap the SDK's SearchService in a SearchServiceProxy that publishes typed message results and paginates on demand, expose it on the ClientProxy, and enable the search index store on the session. Also opens up the timeline item factory's message content builder so the proxy can reuse it. --- ElementX/Sources/Other/SDKListener.swift | 8 + .../Sources/Services/Client/ClientProxy.swift | 8 + .../Services/Client/ClientProxyProtocol.swift | 2 + .../Services/Search/SearchServiceProxy.swift | 122 ++++++ .../Search/SearchServiceProxyProtocol.swift | 31 ++ .../RoomTimelineItemFactory.swift | 2 +- .../RoomTimelineItemFactoryProtocol.swift | 1 + .../UserSession/UserSessionStore.swift | 2 + .../Sources/Generated/SDKGeneratedMocks.swift | 391 +++++++++++------- 9 files changed, 416 insertions(+), 151 deletions(-) create mode 100644 ElementX/Sources/Services/Search/SearchServiceProxy.swift create mode 100644 ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index 48f4644e39..d8f6f41652 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -293,3 +293,11 @@ nonisolated extension SDKListener: RoomDirectorySearchEntriesListener where T == onUpdateClosure(roomEntriesUpdate) } } + +// MARK: SearchServiceProxy + +nonisolated extension SDKListener: SearchServiceResultsListener where T == [SearchServiceResultsUpdate] { + func onUpdate(updates: [SearchServiceResultsUpdate]) { + onUpdateClosure(updates) + } +} 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..317fc86622 --- /dev/null +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -0,0 +1,122 @@ +// 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() + } + + init(searchService: SearchServiceProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) { + self.searchService = searchService + self.timelineItemFactory = timelineItemFactory + } + + 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 { self.makeResult($0) }) + 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)) + default: + content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) + } + default: + content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) + } + + 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..0db0fd11af --- /dev/null +++ b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift @@ -0,0 +1,31 @@ +// 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 } + + 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 +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 91e7f04003..640d2976ad 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -895,7 +895,7 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { // MARK: - Helpers - private func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType { + func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType { switch messageType { case .audio(let content): if content.voice != nil { 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/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()) From aa3bd0283a3ecdcceb755794ef0731a03a1f618d Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 2 Jul 2026 13:25:56 +0300 Subject: [PATCH 2/7] Add a Messages tab to global search Split global search into Rooms and Messages tabs. The Messages tab lists matching events with the room's name and avatar, a sender-prefixed preview, rich media (image/video/audio/file) previews, and a loading indicator, then paginates as you scroll. Queries are debounced and the previous one is superseded so late results can't clobber a newer search. --- .../en.lproj/Untranslated.strings | 4 +- .../UserSessionFlowCoordinator.swift | 33 +- .../Generated/Strings+Untranslated.swift | 6 +- .../Mocks/Generated/GeneratedMocks.swift | 77 ++++ ElementX/Sources/Other/Avatars.swift | 7 +- ElementX/Sources/Other/SDKListener.swift | 6 + .../SearchScreenCoordinator.swift | 10 +- .../SearchScreen/SearchScreenModels.swift | 119 +++++- .../SearchScreen/SearchScreenViewModel.swift | 85 +++- .../SearchScreen/View/SearchScreen.swift | 389 ++++++++++++++++-- .../Services/Search/SearchServiceProxy.swift | 40 +- .../Search/SearchServiceProxyProtocol.swift | 22 +- .../Sources/SearchScreenViewModelTests.swift | 10 +- 13 files changed, 727 insertions(+), 81 deletions(-) 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..45048ed4ee 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -98,6 +98,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) { let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, + searchService: flowParameters.userSession.clientProxy.searchService, + clientProxy: flowParameters.userSession.clientProxy, mediaProvider: flowParameters.userSession.mediaProvider)) let searchStackCoordinator = NavigationStackCoordinator() searchStackCoordinator.setRootCoordinator(searchCoordinator) @@ -222,20 +224,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 +324,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..850c8e29f8 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 globalSearch var value: CGFloat { switch self { @@ -107,7 +108,7 @@ enum UserAvatarSizeOnScreen { case .roomDetails: 44 case .inviteUsers, .knockingUserList, .sessionVerification, - .settings, .threadList, .mediaPreviewDetails: + .settings, .threadList, .mediaPreviewDetails, .globalSearch: 52 case .roomChangeRoles: 56 @@ -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, .globalSearch: 52 case .joinRoom, .spaceHeader, .editSpaceDetails: 64 diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index d8f6f41652..2de4c87169 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -301,3 +301,9 @@ nonisolated extension SDKListener: SearchServiceResultsListener where T == [Sear 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..a5d1a52eaa 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -10,11 +10,13 @@ import SwiftUI struct SearchScreenCoordinatorParameters { let roomSummaryProvider: RoomSummaryProviderProtocol + let searchService: SearchServiceProxyProtocol + let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol } enum SearchScreenCoordinatorAction { - case presentRoom(roomID: String) + case presentRoom(roomID: String, eventID: String?) case cancel } @@ -30,6 +32,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol { init(parameters: SearchScreenCoordinatorParameters) { viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, + searchService: parameters.searchService, + clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider) } @@ -37,8 +41,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..f78ce54b36 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,16 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } init(roomSummaryProvider: RoomSummaryProviderProtocol, + searchService: SearchServiceProxyProtocol, + clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, - initialSearchQuery: String = "") { + initialSearchQuery: String = "", + initialSearchMode: SearchScreenMode = .rooms) { self.roomSummaryProvider = roomSummaryProvider + self.searchService = searchService + self.clientProxy = clientProxy - super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery)), + super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)), mediaProvider: mediaProvider) roomSummaryProvider.roomListPublisher @@ -35,18 +44,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) + searchQueryObservationTask = Task { [weak self] in - guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } + guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates() else { return } for await searchQuery in stream { 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. + loadingObservationTask = Task { [weak self] in + guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } + for await searchQuery in stream { + 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 @@ -56,15 +101,22 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro switch viewAction { case .appeared: - // The provider is shared, so other consumers may have changed its filter while we were off-screen. - // 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 +125,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 { @@ -97,12 +156,6 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } } - /// The actual range values don't matter as long as they contain the lower - /// or upper bounds. updateVisibleRange is a hybrid API that powers both - /// sliding sync visible range update and list paginations. - /// For lists other than the home screen one we don't care about visible ranges, - /// we just need the respective bounds to be there to trigger a next page load or - /// a reset to just one page. private func updateVisibleRange(edge: UIRectEdge) { switch edge { case .top: diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index b570e324a2..23560c749f 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,10 +40,36 @@ 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) @@ -41,10 +77,11 @@ struct SearchScreen: View { .searchable(text: $context.searchQuery, placement: .toolbarPrincipal) .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() { @@ -142,12 +225,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 +244,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()) @@ -192,6 +275,140 @@ 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: .globalSearch), + mediaProvider: context.mediaProvider) + .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) + .accessibilityHidden(true) + } + } +} + +private struct SearchScreenMediaPreviewView: View { + let preview: SearchScreenMediaPreview + let mediaProvider: MediaProviderProtocol? + + private static let mediaSize: CGFloat = 36 + + var body: some View { + HStack(spacing: 8) { + media + .frame(width: Self.mediaSize, height: Self.mediaSize) + + 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, size: .medium, relativeTo: .body) + .foregroundStyle(.compound.iconPrimary) + .frame(width: Self.mediaSize, height: Self.mediaSize) + .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 +459,48 @@ private final class KeyNavigatingSearchTextField: UISearchTextField { struct SearchScreen_Previews: PreviewProvider, TestablePreview { static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(), + 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([]))), + searchService: makeSearchService(), + clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "John Doe") + static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + searchService: makeSearchService(), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation") + static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(results: .mockResults), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation", + initialSearchMode: .messages) + static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(paginationState: .loading), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation", + initialSearchMode: .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() -> ClientProxyMock { + let mock = ClientProxyMock(.init(userID: "@alice:matrix.org")) + 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 + } static var previews: some View { ElementNavigationStack { @@ -262,8 +514,91 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview { .previewDisplayName("No results") ElementNavigationStack { - SearchScreen(context: loadedViewModel.context) + SearchScreen(context: roomsViewModel.context) + } + .previewDisplayName("Rooms") + + ElementNavigationStack { + SearchScreen(context: messagesViewModel.context) } - .previewDisplayName("Loaded") + .previewDisplayName("Messages") + + ElementNavigationStack { + SearchScreen(context: loadingMessagesViewModel.context) + } + .previewDisplayName("Loading messages") + } +} + +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/Search/SearchServiceProxy.swift b/ElementX/Sources/Services/Search/SearchServiceProxy.swift index 317fc86622..43f642e966 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxy.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -11,27 +11,41 @@ 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(()) @@ -40,7 +54,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { return .failure(.sdkError(error)) } } - + func paginate() async { do { try await searchService.paginate() @@ -48,12 +62,12 @@ class SearchServiceProxy: SearchServiceProxyProtocol { 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): @@ -88,15 +102,15 @@ class SearchServiceProxy: SearchServiceProxyProtocol { results = values.compactMap { self.makeResult($0) } } } - + resultsSubject.send(results) } - + private func makeResult(_ searchResult: MatrixRustSDK.SearchServiceResult) -> 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): @@ -111,7 +125,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { default: content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) } - + return SearchServiceResult(roomID: roomID, eventID: result.eventId, sender: sender, diff --git a/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift index 0db0fd11af..4378d8deaf 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift @@ -15,9 +15,11 @@ enum SearchServiceProxyError: 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 } @@ -29,3 +31,19 @@ struct SearchServiceResult { 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/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index a862b9254f..71a607256f 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,14 @@ struct SearchScreenViewModelTests { } init() { + let searchService = SearchServiceProxyMock() + searchService.underlyingResultsPublisher = CurrentValueSubject<[SearchServiceResult], Never>([]).asCurrentValuePublisher() + searchService.underlyingPaginationStatePublisher = CurrentValueSubject(.idle(endReached: true)).asCurrentValuePublisher() + searchService.setQueryReturnValue = .success(()) + viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + searchService: searchService, + clientProxy: ClientProxyMock(.init()), mediaProvider: MediaProviderMock(.init())) } @@ -31,7 +39,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 From 6c9946f2143112ef8aa4b0539e7d653c18789af5 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 2 Jul 2026 13:26:13 +0300 Subject: [PATCH 3/7] Index polls, stickers and more event types in search results The SDK now surfaces polls, stickers, redactions and live location shares alongside plain messages. Map them to their preview content instead of dropping them as unsupported events. --- ElementX/Sources/Services/Search/SearchServiceProxy.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ElementX/Sources/Services/Search/SearchServiceProxy.swift b/ElementX/Sources/Services/Search/SearchServiceProxy.swift index 43f642e966..c04deb18fb 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxy.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -119,6 +119,14 @@ class SearchServiceProxy: SearchServiceProxyProtocol { 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: content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) } From c4b38c3339688aa4a0f6ed6735ddf606cc3488b3 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 2 Jul 2026 16:42:00 +0300 Subject: [PATCH 4/7] Bump the RustSDK to v26.07.02 --- ElementX.xcodeproj/project.pbxproj | 18 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- project.yml | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) 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/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 From 88e7c819a9536bce36fb02b6edba6178ec38b5cc Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 2 Jul 2026 17:40:14 +0300 Subject: [PATCH 5/7] Fix search screen preview tests and add snapshots --- .../Screens/SearchScreen/View/SearchScreen.swift | 13 ++++++++++++- .../PreviewTests/searchScreen.Empty-iPad-en-GB.png | 4 ++-- .../PreviewTests/searchScreen.Empty-iPad-pseudo.png | 4 ++-- .../searchScreen.Empty-iPhone-en-GB.png | 4 ++-- .../searchScreen.Empty-iPhone-pseudo.png | 4 ++-- .../searchScreen.Loading-messages-iPad-en-GB.png | 3 +++ .../searchScreen.Loading-messages-iPad-pseudo.png | 3 +++ .../searchScreen.Loading-messages-iPhone-en-GB.png | 3 +++ .../searchScreen.Loading-messages-iPhone-pseudo.png | 3 +++ .../searchScreen.Messages-iPad-en-GB.png | 3 +++ .../searchScreen.Messages-iPad-pseudo.png | 3 +++ .../searchScreen.Messages-iPhone-en-GB.png | 3 +++ .../searchScreen.Messages-iPhone-pseudo.png | 3 +++ .../searchScreen.No-results-iPad-en-GB.png | 4 ++-- .../searchScreen.No-results-iPad-pseudo.png | 4 ++-- .../searchScreen.No-results-iPhone-en-GB.png | 4 ++-- .../searchScreen.No-results-iPhone-pseudo.png | 4 ++-- .../PreviewTests/searchScreen.Rooms-iPad-en-GB.png | 3 +++ .../PreviewTests/searchScreen.Rooms-iPad-pseudo.png | 3 +++ .../searchScreen.Rooms-iPhone-en-GB.png | 3 +++ .../searchScreen.Rooms-iPhone-pseudo.png | 3 +++ 21 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loading-messages-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Messages-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Rooms-iPhone-pseudo.png diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index 23560c749f..f0f3fd3be5 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -74,7 +74,7 @@ struct SearchScreen: View { } .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) @@ -213,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 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 From 57f9f6893960879eca10fba91945f389d91b0789 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 3 Jul 2026 11:57:23 +0300 Subject: [PATCH 6/7] Address search PR review comments - Drop the redundant searchService coordinator/view model parameter; derive it from the client proxy. - Rename the globalSearch avatar size to search. - Lift the query observation streams out of their tasks. - Restore the appeared and updateVisibleRange comments. - Use CompoundIcon defaults and a scaledFrame for media previews. - Simplify the result mapping with compactMap(makeResult) and drop unsupported events by returning nil. - Move the preview helpers beneath the previews. --- .../UserSessionFlowCoordinator.swift | 1 - ElementX/Sources/Other/Avatars.swift | 8 +-- .../SearchScreenCoordinator.swift | 2 - .../SearchScreen/SearchScreenViewModel.swift | 19 +++++-- .../SearchScreen/View/SearchScreen.swift | 56 +++++++++---------- .../Services/Search/SearchServiceProxy.swift | 8 +-- .../Sources/SearchScreenViewModelTests.swift | 6 +- 7 files changed, 50 insertions(+), 50 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 45048ed4ee..3248071a56 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -98,7 +98,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) { let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, - searchService: flowParameters.userSession.clientProxy.searchService, clientProxy: flowParameters.userSession.clientProxy, mediaProvider: flowParameters.userSession.mediaProvider)) let searchStackCoordinator = NavigationStackCoordinator() diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 850c8e29f8..676db16a35 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -87,7 +87,7 @@ enum UserAvatarSizeOnScreen { case threadSummary case map case classicAppAccount - case globalSearch + case search var value: CGFloat { switch self { @@ -108,7 +108,7 @@ enum UserAvatarSizeOnScreen { case .roomDetails: 44 case .inviteUsers, .knockingUserList, .sessionVerification, - .settings, .threadList, .mediaPreviewDetails, .globalSearch: + .settings, .threadList, .mediaPreviewDetails, .search: 52 case .roomChangeRoles: 56 @@ -131,7 +131,7 @@ enum RoomAvatarSizeOnScreen { case timeline case leaveSpace case messageForwarding - case globalSearch + case search case roomSelection case details case editRoomDetails @@ -157,7 +157,7 @@ enum RoomAvatarSizeOnScreen { .spaceAddRooms: 36 case .chats, .spaces, .spaceSettings, - .spaceAddRoomsSelected, .globalSearch: + .spaceAddRoomsSelected, .search: 52 case .joinRoom, .spaceHeader, .editSpaceDetails: 64 diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index a5d1a52eaa..12f5fc5d20 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -10,7 +10,6 @@ import SwiftUI struct SearchScreenCoordinatorParameters { let roomSummaryProvider: RoomSummaryProviderProtocol - let searchService: SearchServiceProxyProtocol let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol } @@ -32,7 +31,6 @@ final class SearchScreenCoordinator: CoordinatorProtocol { init(parameters: SearchScreenCoordinatorParameters) { viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, - searchService: parameters.searchService, clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider) } diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index f78ce54b36..f859b78e0b 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -25,14 +25,13 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } init(roomSummaryProvider: RoomSummaryProviderProtocol, - searchService: SearchServiceProxyProtocol, clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, initialSearchQuery: String = "", initialSearchMode: SearchScreenMode = .rooms) { self.roomSummaryProvider = roomSummaryProvider - self.searchService = searchService self.clientProxy = clientProxy + searchService = clientProxy.searchService super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)), mediaProvider: mediaProvider) @@ -68,18 +67,18 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } .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).debounce(for: .milliseconds(250)).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 - guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } - for await searchQuery in stream { + for await searchQuery in searchQueryStream { self?.state.isLoadingRooms = !searchQuery.isEmpty self?.state.isLoadingMessages = !searchQuery.isEmpty } @@ -101,6 +100,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro switch viewAction { case .appeared: + // The provider is shared, so other consumers may have changed its filter while we were off-screen. + // 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, eventID: nil)) @@ -156,6 +157,12 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } } + /// The actual range values don't matter as long as they contain the lower + /// or upper bounds. updateVisibleRange is a hybrid API that powers both + /// sliding sync visible range update and list paginations. + /// For lists other than the home screen one we don't care about visible ranges, + /// we just need the respective bounds to be there to trigger a next page load or + /// a reset to just one page. private func updateVisibleRange(edge: UIRectEdge) { switch edge { case .top: diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index f0f3fd3be5..dfacbc0adc 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -267,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) @@ -338,7 +338,7 @@ private struct SearchScreenMessageCell: View { private var avatar: some View { if dynamicTypeSize < .accessibility3 { RoomAvatarImage(avatar: message.roomAvatar, - avatarSize: .room(on: .globalSearch), + avatarSize: .room(on: .search), mediaProvider: context.mediaProvider) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .accessibilityHidden(true) @@ -350,12 +350,10 @@ private struct SearchScreenMediaPreviewView: View { let preview: SearchScreenMediaPreview let mediaProvider: MediaProviderProtocol? - private static let mediaSize: CGFloat = 36 - var body: some View { HStack(spacing: 8) { media - .frame(width: Self.mediaSize, height: Self.mediaSize) + .scaledFrame(width: 36, height: 36) VStack(alignment: .leading, spacing: 0) { Text(preview.title) @@ -390,9 +388,9 @@ private struct SearchScreenMediaPreviewView: View { } private func icon(_ icon: KeyPath) -> some View { - CompoundIcon(icon, size: .medium, relativeTo: .body) + CompoundIcon(icon) .foregroundStyle(.compound.iconPrimary) - .frame(width: Self.mediaSize, height: Self.mediaSize) + .scaledFrame(width: 36, height: 36) .background(.compound.iconOnSolidPrimary, in: RoundedRectangle(cornerRadius: 4, style: .continuous)) } @@ -470,49 +468,27 @@ private final class KeyNavigatingSearchTextField: UISearchTextField { struct SearchScreen_Previews: PreviewProvider, TestablePreview { static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), - searchService: makeSearchService(), clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init())) static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), - searchService: makeSearchService(), clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "John Doe") static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - searchService: makeSearchService(), clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "Foundation") static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), - searchService: makeSearchService(results: .mockResults), - clientProxy: makeClientProxy(), + clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "Foundation", initialSearchMode: .messages) static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), - searchService: makeSearchService(paginationState: .loading), - clientProxy: makeClientProxy(), + clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "Foundation", initialSearchMode: .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() -> ClientProxyMock { - let mock = ClientProxyMock(.init(userID: "@alice:matrix.org")) - 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 - } - static var previews: some View { ElementNavigationStack { SearchScreen(context: emptyViewModel.context) @@ -539,6 +515,24 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview { } .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] { diff --git a/ElementX/Sources/Services/Search/SearchServiceProxy.swift b/ElementX/Sources/Services/Search/SearchServiceProxy.swift index c04deb18fb..9aa17448ff 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxy.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -71,7 +71,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { for update in updates { switch update { case .append(let values): - results.append(contentsOf: values.compactMap { self.makeResult($0) }) + results.append(contentsOf: values.compactMap(makeResult)) case .clear: results.removeAll() case .pushFront(let value): @@ -99,7 +99,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { case .truncate(let length): results.removeSubrange(Int(length).. Date: Fri, 3 Jul 2026 12:17:41 +0300 Subject: [PATCH 7/7] Group RoomTimelineItemFactory public methods under Public/Private marks Move the protocol methods to the top under a Public mark and gather the helpers beneath a Private mark. --- .../RoomTimelineItemFactory.swift | 156 +++++++++--------- 1 file changed, 79 insertions(+), 77 deletions(-) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 640d2976ad..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 - - 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 {