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.
This commit is contained in:
Stefan Ceriu
2026-07-02 13:04:21 +03:00
parent 9012161d9d
commit 5851100f07
9 changed files with 418 additions and 153 deletions
@@ -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<Void, SearchServiceProxyError> {
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)..<results.count)
case .reset(let values):
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):
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))
}
}
}