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:
Stefan Ceriu
2026-06-19 13:50:17 +03:00
committed by GitHub
parent 9a6f7595e5
commit 7f1b6086ae
58 changed files with 731 additions and 780 deletions
@@ -43,9 +43,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - retaining purpose
private var startChatFlowCoordinator: StartChatFlowCoordinator?
// periphery:ignore - retaining purpose
private var globalSearchScreenCoordinator: GlobalSearchScreenCoordinator?
private var cancellables = Set<AnyCancellable>()
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
@@ -172,11 +169,9 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.processEvent(.presentTransferOwnershipScreen(roomID: roomID))
}
case .globalSearch:
presentGlobalSearch()
case .chatBackupSettings:
actionsSubject.send(.showChatBackupSettings)
case .accountProvisioningLink, .oAuthCallback, .settings, .call:
case .accountProvisioningLink, .oAuthCallback, .settings, .call, .search:
break // These routes cannot be handled.
}
}
@@ -720,45 +715,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
}
}
// MARK: Global search
private func presentGlobalSearch() {
let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider
let coordinator = GlobalSearchScreenCoordinator(parameters: .init(roomSummaryProvider: roomSummaryProvider,
mediaProvider: userSession.mediaProvider))
globalSearchScreenCoordinator = coordinator
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
dismissGlobalSearch()
case .select(let roomID):
dismissGlobalSearch()
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
actionsSubject.send(.switchToChatsTab)
}
}
.store(in: &cancellables)
let hostingController = UIHostingController(rootView: coordinator.toPresentable())
hostingController.view.backgroundColor = .clear
flowParameters.windowManager.globalSearchWindow.rootViewController = hostingController
flowParameters.windowManager.showGlobalSearch()
}
private func dismissGlobalSearch() {
flowParameters.windowManager.globalSearchWindow.rootViewController = nil
flowParameters.windowManager.hideGlobalSearch()
globalSearchScreenCoordinator = nil
}
// MARK: Room Directory Search
private func presentRoomDirectorySearch() {
@@ -89,7 +89,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
.roomDetails, .roomMemberDetails, .userProfile, .thread,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.call, .settings, .share, .transferOwnership,
.globalSearch:
.search:
// These routes aren't in this flow so clear the entire stack.
clearRoute(animated: animated)
case .chatBackupSettings:
@@ -208,7 +208,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above.
case .accountProvisioningLink, .oAuthCallback, .roomList, .userProfile, .call, .settings, .chatBackupSettings, .globalSearch:
case .accountProvisioningLink, .oAuthCallback, .roomList, .userProfile, .call, .settings, .chatBackupSettings, .search:
break // These routes can't be handled.
case .transferOwnership(let roomID):
guard self.roomID == roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
@@ -123,7 +123,7 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
case .accountProvisioningLink, .oAuthCallback,
.roomList, .room, .roomDetails, .event,
.userProfile, .call, .settings, .chatBackupSettings,
.share, .transferOwnership, .thread, .globalSearch:
.share, .transferOwnership, .thread, .search:
break
}
}
@@ -20,7 +20,7 @@ enum UserSessionFlowCoordinatorAction {
}
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
enum HomeTab: Hashable { case chats, spaces }
enum HomeTab: Hashable { case chats, spaces, search }
private let navigationRootCoordinator: NavigationRootCoordinator
private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
@@ -38,6 +38,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let spacesTabFlowCoordinator: SpacesTabFlowCoordinator
private let spacesTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails
private let searchScreenCoordinator: SearchScreenCoordinator?
private let searchTabNavigationStackCoordinator: NavigationStackCoordinator?
private let searchTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails?
// periphery:ignore - retaining purpose
private var settingsFlowCoordinator: SettingsFlowCoordinator?
@@ -92,16 +96,35 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
spacesTabDetails = .init(tag: HomeTab.spaces, title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid)
spacesTabDetails.navigationSplitCoordinator = spacesSplitCoordinator
if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) {
let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
mediaProvider: flowParameters.userSession.mediaProvider))
let searchStackCoordinator = NavigationStackCoordinator()
searchStackCoordinator.setRootCoordinator(searchCoordinator)
searchScreenCoordinator = searchCoordinator
searchTabNavigationStackCoordinator = searchStackCoordinator
searchTabDetails = .init(tag: HomeTab.search, title: UntranslatedL10n.screenHomeTabSearch, icon: \.search, selectedIcon: \.search, isSearch: true)
} else {
searchScreenCoordinator = nil
searchTabNavigationStackCoordinator = nil
searchTabDetails = nil
}
onboardingStackCoordinator = NavigationStackCoordinator()
onboardingFlowCoordinator = OnboardingFlowCoordinator(isNewLogin: isNewLogin,
appLockService: appLockService,
navigationStackCoordinator: onboardingStackCoordinator,
flowParameters: flowParameters)
navigationTabCoordinator.setTabs([
var tabs: [NavigationTabCoordinator<HomeTab>.Tab] = [
.init(coordinator: chatsSplitCoordinator, details: chatsTabDetails),
.init(coordinator: spacesSplitCoordinator, details: spacesTabDetails)
])
]
if let searchTabNavigationStackCoordinator, let searchTabDetails {
tabs.append(.init(coordinator: searchTabNavigationStackCoordinator, details: searchTabDetails))
}
navigationTabCoordinator.setTabs(tabs)
stateMachine = flowParameters.stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial)
configureStateMachine()
@@ -137,12 +160,17 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.share, .transferOwnership, .thread, .globalSearch:
.share, .transferOwnership, .thread:
clearPresentedSheets(animated: animated) // Make sure the presented route is visible.
chatsTabFlowCoordinator.handleAppRoute(appRoute, animated: animated)
if navigationTabCoordinator.selectedTab != .chats {
navigationTabCoordinator.selectedTab = .chats
}
case .search:
// Switch to the dedicated search tab when it's available (iOS 26 + flag), otherwise ignore.
if searchTabNavigationStackCoordinator != nil {
navigationTabCoordinator.selectedTab = .search
}
}
}
@@ -195,6 +223,16 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
private func setupObservers() {
searchScreenCoordinator?.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentRoom(let roomID):
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
}
}
.store(in: &cancellables)
chatsTabFlowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }