diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index 99b96e4c7..b9544ec3a 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -16,8 +16,11 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro private let searchService: SearchServiceProxyProtocol private let userIndicatorController: UserIndicatorControllerProtocol private var searchQueryObservationTask: Task? + private var searchModeObservationTask: Task? private var loadingObservationTask: Task? private var setQueryTask: Task? + /// 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 = .init() var actionsPublisher: AnyPublisher { @@ -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