mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:06:00 +00:00
Search tab (#5754)
* 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.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import SwiftUI
|
||||
|
||||
struct SearchScreenCoordinatorParameters {
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
}
|
||||
|
||||
enum SearchScreenCoordinatorAction {
|
||||
case presentRoom(roomID: String)
|
||||
}
|
||||
|
||||
final class SearchScreenCoordinator: CoordinatorProtocol {
|
||||
private let viewModel: SearchScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<SearchScreenCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<SearchScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: SearchScreenCoordinatorParameters) {
|
||||
viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .presentRoom(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(SearchScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
enum SearchScreenViewModelAction {
|
||||
case presentRoom(roomID: String)
|
||||
}
|
||||
|
||||
struct SearchScreenViewState: BindableState {
|
||||
var rooms = [SearchScreenRoom]()
|
||||
var bindings: SearchScreenViewStateBindings
|
||||
|
||||
var isSearching: Bool {
|
||||
!bindings.searchQuery.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
}
|
||||
|
||||
enum SearchScreenViewAction {
|
||||
case appeared
|
||||
case selectRoom(roomID: String)
|
||||
case reachedTop
|
||||
case reachedBottom
|
||||
}
|
||||
|
||||
struct SearchScreenRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String
|
||||
let avatar: RoomAvatar
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// 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 Combine
|
||||
|
||||
@MainActor
|
||||
protocol SearchScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> { get }
|
||||
var context: SearchScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//
|
||||
// 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 Compound
|
||||
import GameController
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
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 selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise.
|
||||
@State private var isHardwareKeyboardConnected = false
|
||||
|
||||
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.
|
||||
Text(L10n.actionSearch)
|
||||
.font(.compound.headingXLBold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if context.viewState.rooms.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
roomList
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
.searchable(text: $context.searchQuery, placement: .toolbarPrincipal)
|
||||
.searchFocused($isSearchFieldFocused)
|
||||
.autocorrectionDisabled(true)
|
||||
.onSubmit(of: .search) {
|
||||
if let selectedRoomID {
|
||||
context.send(viewAction: .selectRoom(roomID: selectedRoomID))
|
||||
}
|
||||
}
|
||||
.searchResultsKeyboardNavigation(moveUp: { moveSelection(backwards: true) },
|
||||
moveDown: { moveSelection(backwards: false) })
|
||||
// The TabView calls onAppear each time the search tab is selected, so the field re-focuses
|
||||
// and the selection resets on every switch.
|
||||
.onAppear {
|
||||
context.send(viewAction: .appeared)
|
||||
isSearchFieldFocused = true
|
||||
selectedRoomID = context.viewState.rooms.first?.id
|
||||
updateHardwareKeyboardConnected()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
|
||||
updateHardwareKeyboardConnected()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
||||
updateHardwareKeyboardConnected()
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
if context.viewState.isSearching {
|
||||
TitleAndIcon(title: L10n.commonNoResults,
|
||||
subtitle: UntranslatedL10n.screenSearchNoResultsMessage(context.searchQuery),
|
||||
icon: \.search,
|
||||
iconStyle: .defaultSolid)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(40)
|
||||
} else {
|
||||
TitleAndIcon(title: UntranslatedL10n.screenSearchEmptyStateTitle,
|
||||
subtitle: UntranslatedL10n.screenSearchEmptyStateMessage,
|
||||
icon: \.search,
|
||||
iconStyle: .defaultSolid)
|
||||
.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)
|
||||
.onAppear {
|
||||
if room == context.viewState.rooms.first {
|
||||
context.send(viewAction: .reachedTop)
|
||||
} else if room == context.viewState.rooms.last {
|
||||
context.send(viewAction: .reachedBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.compoundList(.plain)
|
||||
}
|
||||
|
||||
private func moveSelection(backwards: Bool) {
|
||||
let rooms = context.viewState.rooms
|
||||
guard let selectedRoomID,
|
||||
let currentIndex = rooms.firstIndex(where: { $0.id == selectedRoomID })
|
||||
else {
|
||||
selectedRoomID = rooms.first?.id
|
||||
return
|
||||
}
|
||||
|
||||
let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1
|
||||
guard rooms.indices.contains(nextIndex) else { return }
|
||||
self.selectedRoomID = rooms[nextIndex].id
|
||||
}
|
||||
|
||||
private func updateHardwareKeyboardConnected() {
|
||||
isHardwareKeyboardConnected = GCKeyboard.coalesced != nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchScreenRoomCell: View {
|
||||
let room: SearchScreenRoom
|
||||
let context: SearchScreenViewModel.Context
|
||||
let isLast: Bool
|
||||
let isSelected: Bool
|
||||
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
context.send(viewAction: .selectRoom(roomID: room.id))
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
avatar
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(room.title)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if !room.description.isEmpty {
|
||||
Text(room.description)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected))
|
||||
.listRowInsets(.init())
|
||||
.listRowSeparator(.hidden)
|
||||
.rowDivider()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
RoomAvatarImage(avatar: room.avatar,
|
||||
avatarSize: .room(on: .globalSearch),
|
||||
mediaProvider: context.mediaProvider)
|
||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchScreenRoomCellButtonStyle: ButtonStyle {
|
||||
let isSelected: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(isSelected || configuration.isPressed ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault)
|
||||
.contentShape(Rectangle())
|
||||
.animation(isSelected ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Forwards hardware up/down arrow presses from the system search field to the given closures.
|
||||
///
|
||||
/// The `.search` role tab owns its text field, so there's no SwiftUI hook for its key presses. We reach the
|
||||
/// field through the navigation stack (as ``SearchFieldStyle`` does) and swap its class for one that overrides
|
||||
/// `pressesBegan` — the same key interception the old global search performed on its own field.
|
||||
func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void) -> some View {
|
||||
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
|
||||
guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else {
|
||||
return
|
||||
}
|
||||
|
||||
if !(textField is KeyNavigatingSearchTextField) {
|
||||
object_setClass(textField, KeyNavigatingSearchTextField.self)
|
||||
}
|
||||
|
||||
KeyNavigatingSearchTextField.keyHandler = { keyCode in
|
||||
switch keyCode {
|
||||
case .keyboardUpArrow:
|
||||
moveUp()
|
||||
case .keyboardDownArrow:
|
||||
moveDown()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `UISearchTextField` whose class is installed at runtime via `object_setClass`, so it must not declare any
|
||||
/// stored properties. The handler is static, which is fine as there's only ever a single search field.
|
||||
private final class KeyNavigatingSearchTextField: UISearchTextField {
|
||||
static var keyHandler: ((UIKeyboardHIDUsage) -> Bool)?
|
||||
|
||||
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
if let keyCode = presses.first?.key?.keyCode, Self.keyHandler?(keyCode) == true {
|
||||
return
|
||||
}
|
||||
super.pressesBegan(presses, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct SearchScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
||||
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([]))),
|
||||
mediaProvider: MediaProviderMock(.init()),
|
||||
initialSearchQuery: "John Doe")
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
SearchScreen(context: emptyViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Empty")
|
||||
|
||||
ElementNavigationStack {
|
||||
SearchScreen(context: noResultsViewModel.context)
|
||||
}
|
||||
.previewDisplayName("No results")
|
||||
|
||||
ElementNavigationStack {
|
||||
SearchScreen(context: loadedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Loaded")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user