mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
7f1b6086ae
* Add a developer flag for the search tab Introduces a globalSearchEnabled user preference (off by default) with a toggle in Developer Options, used to gate the new search experience. * Support search role tabs in the tab coordinator Migrates the NavigationTabCoordinator to SwiftUI's value-based Tab API and adds an isSearch option so a tab can adopt the iOS 26 .search role, which detaches it into the trailing search button in the tab bar. * Moves room search to a new screen Adds a new screen that filters the alternate room summary provider as you type, shows an empty state, and lists matching rooms with their matrix identifier. Supports hardware keyboard selection (arrow keys to move through results, return to open) by reaching into the system search field. * Present search in a dedicated tab Adds the search tab to the chats flow on iOS 26 when the flag is enabled, rendered as the detached .search role button. The Cmd+K shortcut now selects this tab, and the home screen's inline search is hidden while the tab is available. * Remove the old global search screen The dedicated search tab replaces the Cmd+K floating window, so the GlobalSearchScreen, its window plumbing in the WindowManager, and its presentation in the chats flow are all removed.
116 lines
4.4 KiB
Swift
116 lines
4.4 KiB
Swift
//
|
|
// Copyright 2026 Element Creations Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import AsyncAlgorithms
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
typealias SearchScreenViewModelType = StateStoreViewModelV2<SearchScreenViewState, SearchScreenViewAction>
|
|
|
|
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
|
|
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
|
private var searchQueryObservationTask: Task<Void, Never>?
|
|
|
|
private let actionsSubject: PassthroughSubject<SearchScreenViewModelAction, Never> = .init()
|
|
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
|
|
actionsSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
init(roomSummaryProvider: RoomSummaryProviderProtocol,
|
|
mediaProvider: MediaProviderProtocol,
|
|
initialSearchQuery: String = "") {
|
|
self.roomSummaryProvider = roomSummaryProvider
|
|
|
|
super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery)),
|
|
mediaProvider: mediaProvider)
|
|
|
|
roomSummaryProvider.roomListPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] summaries in
|
|
self?.updateRooms(with: summaries)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
searchQueryObservationTask = Task { [weak self] in
|
|
guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return }
|
|
for await searchQuery in stream {
|
|
self?.updateFilter(for: searchQuery)
|
|
}
|
|
}
|
|
|
|
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
|
|
}
|
|
|
|
isolated deinit {
|
|
searchQueryObservationTask?.cancel()
|
|
}
|
|
|
|
// MARK: - Public
|
|
|
|
override func process(viewAction: SearchScreenViewAction) {
|
|
MXLog.info("View model: received view action: \(viewAction)")
|
|
|
|
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))
|
|
case .reachedTop:
|
|
updateVisibleRange(edge: .top)
|
|
case .reachedBottom:
|
|
updateVisibleRange(edge: .bottom)
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func updateFilter(for searchQuery: String) {
|
|
if searchQuery.isEmpty {
|
|
roomSummaryProvider.setFilter(.excludeAll)
|
|
} else {
|
|
roomSummaryProvider.setFilter(.search(query: searchQuery))
|
|
}
|
|
}
|
|
|
|
private func updateRooms(with summaries: [RoomSummary]) {
|
|
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 {
|
|
summary.canonicalAlias
|
|
}
|
|
|
|
return SearchScreenRoom(id: summary.id,
|
|
title: summary.name,
|
|
description: identifier ?? "",
|
|
avatar: summary.avatar)
|
|
}
|
|
}
|
|
|
|
/// 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:
|
|
roomSummaryProvider.updateVisibleRange(0..<0)
|
|
case .bottom:
|
|
let roomCount = roomSummaryProvider.roomListPublisher.value.count
|
|
roomSummaryProvider.updateVisibleRange(roomCount..<roomCount)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|