Debounce message search only and search the active tab lazily

The search screen previously ran both the room filter and the (slow) message
query on every keystroke regardless of which tab was active, so the fast room
results were held up behind message search.

Now only the active tab is searched, and the other tab is (re)searched when it
becomes active, but only if the query actually changed since it last ran,
tracked per-tab in searchedQueries. Room search reacts to every keystroke while
message search keeps the 250ms debounce, now applied inside the messages branch
of updateFilter rather than upstream of both.
This commit is contained in:
Stefan Ceriu
2026-07-21 15:28:42 +03:00
parent 4eb0863f3a
commit da6eca78f0
@@ -16,8 +16,11 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
private let searchService: SearchServiceProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var searchQueryObservationTask: Task<Void, Never>?
private var searchModeObservationTask: Task<Void, Never>?
private var loadingObservationTask: Task<Void, Never>?
private var setQueryTask: Task<Void, Never>?
/// The query each tab last searched, so switching tabs only re-searches when the query changed.
private var searchedQueries: [SearchScreenMode: String] = [:]
private let actionsSubject: PassthroughSubject<SearchScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
@@ -68,20 +71,29 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
}
.store(in: &cancellables)
let debouncedSearchQueryStream = context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates()
// Room search is fast so it reacts to every keystroke; message search debounces inside updateFilter.
let searchQueryStream = context.observe(\.viewState.bindings.searchQuery).removeDuplicates()
searchQueryObservationTask = Task { [weak self] in
for await searchQuery in debouncedSearchQueryStream {
for await searchQuery in searchQueryStream {
self?.updateFilter(for: searchQuery)
}
}
// Re-run the search when switching tabs so the newly active tab reflects the current query.
let searchModeStream = context.observe(\.viewState.bindings.searchMode).removeDuplicates()
searchModeObservationTask = Task { [weak self] in
for await _ in searchModeStream {
guard let self else { return }
updateFilter(for: state.bindings.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()
// message query above, so the empty state doesn't flash while the first search is still pending.
let loadingQueryStream = context.observe(\.viewState.bindings.searchQuery).removeDuplicates()
loadingObservationTask = Task { [weak self] in
for await searchQuery in searchQueryStream {
self?.state.isLoadingRooms = !searchQuery.isEmpty
self?.state.isLoadingMessages = !searchQuery.isEmpty
for await searchQuery in loadingQueryStream {
self?.setActiveTabLoading(!searchQuery.isEmpty)
}
}
@@ -90,6 +102,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
isolated deinit {
searchQueryObservationTask?.cancel()
searchModeObservationTask?.cancel()
loadingObservationTask?.cancel()
setQueryTask?.cancel()
}
@@ -103,7 +116,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
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)
updateFilter(for: state.bindings.searchQuery, forced: true)
case .selectRoom(let roomID):
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil))
case .selectMessage(let roomID, let eventID):
@@ -126,17 +139,34 @@ 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 {
private func updateFilter(for searchQuery: String, forced: Bool = false) {
guard !searchQuery.isEmpty else {
// Supersede any in-flight query so its results can't land after a newer one's.
setQueryTask?.cancel()
searchedQueries.removeAll()
roomSummaryProvider.setFilter(.excludeAll)
state.messages = []
} else {
return
}
// Only search the active tab. Room search is fast while message search is slow, so we
// avoid one blocking the other; the other tab is (re)searched when it becomes active.
let mode = state.bindings.searchMode
// Skip a tab that already ran this query (e.g. switching back and forth) unless forced.
guard forced || searchedQueries[mode] != searchQuery else { return }
setQueryTask?.cancel()
setActiveTabLoading(true)
switch mode {
case .rooms:
searchedQueries[mode] = searchQuery
roomSummaryProvider.setFilter(.search(query: searchQuery))
case .messages:
setQueryTask = Task { [weak self] in
guard let self else { return }
// Debounce message queries; superseded keystrokes cancel this before it commits.
try? await Task.sleep(for: .milliseconds(250))
guard let self, !Task.isCancelled else { return }
searchedQueries[mode] = searchQuery
if case .failure = await searchService.setQuery(searchQuery), !Task.isCancelled {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
@@ -144,6 +174,15 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
}
}
private func setActiveTabLoading(_ isLoading: Bool) {
switch state.bindings.searchMode {
case .rooms:
state.isLoadingRooms = isLoading
case .messages:
state.isLoadingMessages = isLoading
}
}
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