Show recent searches on the search screen

The search screen's empty state now lists the queries the user searched
for and the rooms they opened from the results, newest first, capped at 25
and stored in AppSettings. Tapping a query fills the search field, tapping
a room opens it, and both are reachable with a hardware keyboard.

Rooms are resolved from their identifiers as the room list loads, and
those the user has left or is only invited to are dropped. The history is
cleared on logout.
This commit is contained in:
Stefan Ceriu
2026-09-10 17:26:35 +03:00
parent 9bff070648
commit 482cc0fee8
16 changed files with 295 additions and 21 deletions
@@ -2,6 +2,7 @@
"screen_search_empty_state_message" = "Search for chats and messages";
"screen_search_empty_state_title" = "Start searching...";
"screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term.";
"screen_search_recent_searches" = "Recent searches";
"screen_search_tab_chats" = "Chats";
"screen_search_tab_messages" = "Messages";
"soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages.";
@@ -73,6 +73,7 @@ final nonisolated class AppSettings: @unchecked Sendable {
func resetSessionSpecificSettings() {
MXLog.warning("Resetting the user session specific AppSettings.")
resetHasRunIdentityConfirmationOnboarding()
resetSearchBreadcrumbs()
}
// MARK: - Hooks
@@ -360,6 +361,12 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: false)
var roomListNotificationCountEnabled: Bool
// MARK: - Search Screen
/// The queries the user searched for and the rooms they opened from the results, most recent first.
@UserPreference(defaultValue: [SearchBreadcrumb]())
var searchBreadcrumbs: [SearchBreadcrumb]
// MARK: - Room Screen
@UserPreference(defaultValue: AppBuildType.current == .debug)
@@ -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.
//
/// An entry in the search screen's history, shown when the user hasn't typed a query yet.
nonisolated enum SearchBreadcrumb: Codable, Hashable {
/// A query the user searched for.
case query(String)
/// A room the user opened from the search results.
case room(roomID: String)
}
@@ -102,7 +102,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
clientProxy: flowParameters.userSession.clientProxy,
mediaProvider: flowParameters.userSession.mediaProvider,
userIndicatorController: flowParameters.userIndicatorController))
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings))
let searchStackCoordinator = NavigationStackCoordinator()
searchStackCoordinator.setRootCoordinator(searchCoordinator)
@@ -20,6 +20,8 @@ internal nonisolated enum UntranslatedL10n {
internal static func screenSearchNoResultsMessage(_ p1: Any) -> String {
return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1))
}
/// Recent searches
internal static var screenSearchRecentSearches: String { return UntranslatedL10n.tr("Untranslated", "screen_search_recent_searches") }
/// Chats
internal static var screenSearchTabChats: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_chats") }
/// Messages
+2 -1
View File
@@ -136,6 +136,7 @@ enum RoomAvatarSizeOnScreen {
case leaveSpace
case messageForwarding
case search
case searchHistory
case roomSelection
case details
case editRoomDetails
@@ -153,7 +154,7 @@ enum RoomAvatarSizeOnScreen {
switch self {
case .timeline, .leaveSpace, .notificationSettings, .roomDirectorySearch,
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
.spaceFilters:
.spaceFilters, .searchHistory:
32
case .messageForwarding, .roomSelection,
.spaceAddRooms:
@@ -13,6 +13,7 @@ struct SearchScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let appSettings: AppSettings
}
enum SearchScreenCoordinatorAction {
@@ -34,7 +35,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol {
viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings)
}
func start() {
@@ -31,6 +31,7 @@ enum SearchScreenMode: CaseIterable, Identifiable {
struct SearchScreenViewState: BindableState {
var rooms = [SearchScreenRoom]()
var messages = [SearchScreenMessage]()
var breadcrumbs = [SearchScreenBreadcrumb]()
var isLoadingRooms = false
var isLoadingMessages = false
var bindings: SearchScreenViewStateBindings
@@ -59,6 +60,32 @@ struct SearchScreenRoom: Identifiable, Equatable {
let title: String
let description: String
let avatar: RoomAvatar
init(_ summary: RoomSummary) {
let identifier = if summary.isDirect {
summary.heroes.first?.id ?? summary.canonicalAlias
} else {
summary.canonicalAlias
}
id = summary.id
title = summary.name
description = identifier ?? ""
avatar = summary.avatar
}
}
/// An entry in the search history shown before the user has typed a query.
enum SearchScreenBreadcrumb: Identifiable, Equatable {
case query(String)
case room(SearchScreenRoom)
var id: String {
switch self {
case .query(let query): "query-\(query)"
case .room(let room): "room-\(room.id)"
}
}
}
struct SearchScreenMessage: Identifiable, Equatable {
@@ -12,9 +12,14 @@ import SwiftUI
typealias SearchScreenViewModelType = StateStoreViewModelV2<SearchScreenViewState, SearchScreenViewAction>
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
/// The number of entries kept in the search history.
private static let maximumBreadcrumbCount = 25
private let roomSummaryProvider: RoomSummaryProviderProtocol
private let clientProxy: ClientProxyProtocol
private let searchService: SearchServiceProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings
private var searchQueryObservationTask: Task<Void, Never>?
private var searchModeObservationTask: Task<Void, Never>?
private var loadingObservationTask: Task<Void, Never>?
@@ -31,11 +36,14 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings,
initialSearchQuery: String = "",
initialSearchMode: SearchScreenMode = .rooms) {
self.roomSummaryProvider = roomSummaryProvider
self.clientProxy = clientProxy
searchService = clientProxy.searchService
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)),
mediaProvider: mediaProvider)
@@ -64,6 +72,18 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
}
.store(in: &cancellables)
// The room list is empty on a cold start, so the room breadcrumbs are re-resolved as it loads.
appSettings.searchBreadcrumbsPublisher
.combineLatest(clientProxy.staticRoomSummaryProvider.roomListPublisher)
.receive(on: DispatchQueue.main)
.sink { [weak self] storedBreadcrumbs, _ in
guard let self else { return }
let breadcrumbs = makeBreadcrumbs(from: storedBreadcrumbs)
guard breadcrumbs != state.breadcrumbs else { return }
state.breadcrumbs = breadcrumbs
}
.store(in: &cancellables)
searchService.paginationStatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] paginationState in
@@ -98,6 +118,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
}
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
state.breadcrumbs = makeBreadcrumbs(from: appSettings.searchBreadcrumbs)
}
isolated deinit {
@@ -118,8 +139,10 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
// Re-apply ours on every appearance to keep the displayed results in sync with the query.
updateFilter(for: state.bindings.searchQuery, forced: true)
case .selectRoom(let roomID):
recordBreadcrumbs(roomID: roomID)
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil))
case .selectMessage(let roomID, let eventID):
recordBreadcrumbs(roomID: roomID)
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
case .reachedTop:
if state.bindings.searchMode == .rooms {
@@ -174,6 +197,33 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
}
}
private func makeBreadcrumbs(from storedBreadcrumbs: [SearchBreadcrumb]) -> [SearchScreenBreadcrumb] {
storedBreadcrumbs.compactMap { breadcrumb in
switch breadcrumb {
case .query(let query):
return .query(query)
case .room(let roomID):
// Drop rooms the user has since left or is only invited to, we've got nothing to show for them.
guard let summary = clientProxy.roomSummaryForIdentifier(roomID), summary.joinRequestType == nil else { return nil }
return .room(SearchScreenRoom(summary))
}
}
}
/// Stores the query that led to the selected result, along with the room it belongs to.
private func recordBreadcrumbs(roomID: String) {
let searchQuery = state.bindings.searchQuery
let newBreadcrumbs: [SearchBreadcrumb] = searchQuery.isEmpty ? [.room(roomID: roomID)] : [.query(searchQuery), .room(roomID: roomID)]
var breadcrumbs = appSettings.searchBreadcrumbs
for breadcrumb in newBreadcrumbs {
breadcrumbs.removeAll { $0 == breadcrumb }
breadcrumbs.insert(breadcrumb, at: 0)
}
appSettings.searchBreadcrumbs = Array(breadcrumbs.prefix(Self.maximumBreadcrumbCount))
}
private func setActiveTabLoading(_ isLoading: Bool) {
switch state.bindings.searchMode {
case .rooms:
@@ -186,18 +236,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
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
state.rooms = summaries.map { summary in
let identifier = if summary.isDirect {
summary.heroes.first?.id ?? summary.canonicalAlias
} else {
summary.canonicalAlias
}
return SearchScreenRoom(id: summary.id,
title: summary.name,
description: identifier ?? "",
avatar: summary.avatar)
}
state.rooms = summaries.map(SearchScreenRoom.init)
}
/// The actual range values don't matter as long as they contain the lower
@@ -21,9 +21,14 @@ struct SearchScreen: View {
/// The selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise.
@State private var isHardwareKeyboardConnected = false
/// The ids of the results in the active tab, in display order.
/// The ids of the rows the user can select, in display order. These are the breadcrumbs
/// until a query is entered, after which they're the active tab's results.
private var selectableIDs: [String] {
switch context.viewState.bindings.searchMode {
guard context.viewState.isSearching else {
return context.viewState.breadcrumbs.map(\.id)
}
return switch context.viewState.bindings.searchMode {
case .rooms: context.viewState.rooms.map(\.id)
case .messages: context.viewState.messages.map(\.id)
}
@@ -126,6 +131,8 @@ struct SearchScreen: View {
iconStyle: .defaultSolid)
.frame(maxWidth: .infinity)
.padding(40)
} else if !context.viewState.breadcrumbs.isEmpty {
breadcrumbsList
} else {
TitleAndIcon(title: UntranslatedL10n.screenSearchEmptyStateTitle,
subtitle: UntranslatedL10n.screenSearchEmptyStateMessage,
@@ -176,6 +183,27 @@ struct SearchScreen: View {
.compoundList(.plain)
}
private var breadcrumbsList: some View {
List {
Section {
ForEach(context.viewState.breadcrumbs) { breadcrumb in
let isSelected = isHardwareKeyboardConnected && selectedID == breadcrumb.id
switch breadcrumb {
case .query(let query):
SearchScreenQueryCell(query: query, isSelected: isSelected) { context.searchQuery = query }
case .room(let room):
SearchScreenRoomCell(room: room, context: context, isSelected: isSelected, avatarSize: .searchHistory)
}
}
} header: {
Text(UntranslatedL10n.screenSearchRecentSearches)
.compoundListSectionHeader()
}
}
.compoundList(.plain)
}
/// Hidden buttons that switch tabs via ⌘1/⌘2, the common shortcut for jumping to tab N.
private var tabShortcuts: some View {
ForEach(Array(SearchScreenMode.allCases.enumerated()), id: \.element) { index, mode in
@@ -201,6 +229,19 @@ struct SearchScreen: View {
private func selectCurrent() {
guard let selectedID else { return }
guard context.viewState.isSearching else {
switch context.viewState.breadcrumbs.first(where: { $0.id == selectedID }) {
case .query(let query):
context.searchQuery = query
case .room(let room):
context.send(viewAction: .selectRoom(roomID: room.id))
case nil:
break
}
return
}
switch context.viewState.bindings.searchMode {
case .rooms:
context.send(viewAction: .selectRoom(roomID: selectedID))
@@ -230,6 +271,7 @@ private struct SearchScreenRoomCell: View {
let room: SearchScreenRoom
let context: SearchScreenViewModel.Context
let isSelected: Bool
var avatarSize: RoomAvatarSizeOnScreen = .search
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@@ -268,7 +310,7 @@ private struct SearchScreenRoomCell: View {
private var avatar: some View {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .search),
avatarSize: .room(on: avatarSize),
mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
@@ -287,6 +329,42 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle {
}
}
private struct SearchScreenQueryCell: View {
let query: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
CompoundIcon(\.history, size: .small, relativeTo: .compound.bodyLG)
.foregroundStyle(.compound.iconPrimary)
.scaledFrame(width: 32, height: 32)
.background(.compound.bgSubtleSecondary, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.accessibilityHidden(true)
ZStack(alignment: .leading) {
// Hidden text with 2 lines to match the height of the room rows, scaling with dynamic text.
Text(verbatim: " \n ")
.font(.compound.bodyLG)
Text(query)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected))
.listRowInsets(.init())
.listRowSeparator(.hidden)
.rowDivider()
}
}
private struct SearchScreenMessageCell: View {
let message: SearchScreenMessage
let context: SearchScreenViewModel.Context
@@ -471,36 +549,56 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings.volatile())
static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings.volatile(),
initialSearchQuery: "John Doe")
static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings.volatile(),
initialSearchQuery: "Foundation")
static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)),
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings.volatile(),
initialSearchQuery: "Foundation",
initialSearchMode: .messages)
static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)),
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings.volatile(),
initialSearchQuery: "Foundation",
initialSearchMode: .messages)
static let breadcrumbsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: makeAppSettings(breadcrumbs: [.room(roomID: "!room1:matrix.org"),
.query("Bob"),
.room(roomID: "!room5:matrix.org"),
.query("Element HR"),
.query("Alice")]))
static var previews: some View {
ElementNavigationStack {
SearchScreen(context: emptyViewModel.context)
}
.previewDisplayName("Empty")
ElementNavigationStack {
SearchScreen(context: breadcrumbsViewModel.context)
}
.previewDisplayName("Recent searches")
ElementNavigationStack {
SearchScreen(context: noResultsViewModel.context)
}
@@ -530,13 +628,22 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
return mock
}
private static func makeAppSettings(breadcrumbs: [SearchBreadcrumb]) -> AppSettings {
let appSettings = AppSettings.volatile()
appSettings.searchBreadcrumbs = breadcrumbs
return appSettings
}
private static func makeClientProxy(searchService: SearchServiceProxyMock = makeSearchService()) -> ClientProxyMock {
let mock = ClientProxyMock(.init(userID: "@alice:matrix.org"))
mock.searchService = searchService
let names: [String: String] = ["!room1:matrix.org": "Alice", "!room2:matrix.org": "Bob", "!room3:matrix.org": "Coline",
"!room4:matrix.org": "Bob", "!room5:matrix.org": "Office", "!room6:matrix.org": "Data analytics",
"!room7:matrix.org": "Alice", "!room8:matrix.org": "Bob", "!room9:matrix.org": "Coline"]
mock.roomSummaryForIdentifierClosure = { id in .mock(id: id, name: names[id] ?? id) }
mock.roomSummaryForIdentifierClosure = { id in
let name = names[id] ?? id
return .mock(id: id, name: name, canonicalAlias: "#\(name.lowercased()):matrix.org")
}
return mock
}
}