diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 98b52e22e..009ff1b23 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -378,6 +378,7 @@ 3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; }; 3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2AF1828A5B76B7C371240FE /* StateRoomTimelineView.swift */; }; 3B5AB5CF8D8163599C5BF19B /* PillViewOnBubblePreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2005B4AD70A792340E2694F1 /* PillViewOnBubblePreviews.swift */; }; + 3BB19AB9CDA3034AE39C039A /* SearchBreadcrumb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */; }; 3BEBDCB42BABFA3B456FECA7 /* MapTilerURLBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D262A26713C18BB70C82CA5 /* MapTilerURLBuilderTests.swift */; }; 3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */; }; 3C549A0BF39F8A854D45D9FD /* EmbeddedElementCall in Frameworks */ = {isa = PBXBuildFile; productRef = 424B603C07AEA6FCA7C8E7C9 /* EmbeddedElementCall */; }; @@ -495,6 +496,7 @@ 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; }; 4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; }; 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */; }; + 4EF76B21E1128FA18F8FB7A9 /* SearchBreadcrumb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */; }; 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */; }; 4F51C7C8131D060108B80575 /* NavigationCoordinators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79351405CD99312A182B2488 /* NavigationCoordinators.swift */; }; 4FBC9E416582352432EBAB7F /* ClientFactoryHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3561843E139EA7CDAED0933 /* ClientFactoryHook.swift */; }; @@ -1405,6 +1407,7 @@ E184FFAD32342D3D6E2F89AA /* PinnedEventsTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D53754227CEBD06358956D7 /* PinnedEventsTimelineScreenCoordinator.swift */; }; E1A0752244567A25E376A2F4 /* AdvancedSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A1B80FE6E3BA72F9C748AD /* AdvancedSettingsScreenViewModel.swift */; }; E1C67E5D9E22135A8FEBBD60 /* StackedAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8558D41DD4B553A752C868A /* StackedAvatarsView.swift */; }; + E1CC93B1842137A8AB25B815 /* SearchBreadcrumb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */; }; E1DE8A5422643F759385290F /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD5523BDEDB247E29228476 /* AppSettings.swift */; }; E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; }; E26E702F325F0AA49B8A5CFD /* RoomPowerLevelsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */; }; @@ -1796,6 +1799,7 @@ 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelProtocol.swift; sourceTree = ""; }; 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsFlowCoordinator.swift; sourceTree = ""; }; 094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenCoordinator.swift; sourceTree = ""; }; + 095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBreadcrumb.swift; sourceTree = ""; }; 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentlyUsedEmoji.swift; sourceTree = ""; }; 0A3534D37BFC074DD26F2FFE /* RoomThreadListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenModels.swift; sourceTree = ""; }; @@ -6856,6 +6860,7 @@ 8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */, 69A05E85E4872C3221C5C287 /* RemotePreference.swift */, A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */, + 095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */, 10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */, 0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */, F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */, @@ -8244,6 +8249,7 @@ 5E73893669BB56307A44AAAD /* RoomListActivityVisibility.swift in Sources */, 17BC15DA08A52587466698C5 /* RoomMessageEventStringBuilder.swift in Sources */, 06D17F7813AA931FF18FD5D0 /* SDKListener.swift in Sources */, + 4EF76B21E1128FA18F8FB7A9 /* SearchBreadcrumb.swift in Sources */, 329D65DD7CC0E42CAD282EF7 /* Secrets.swift in Sources */, 7573D682F089205F7F1D96CF /* SessionDirectories.swift in Sources */, 422E8D182CA688D4565CD1E1 /* String.swift in Sources */, @@ -8494,6 +8500,7 @@ 6F76F34F4CAD400F5015EC5C /* RemoteSettingsHook.swift in Sources */, 1C4CB9009E50E6535883D5B2 /* RestorationToken.swift in Sources */, 6885C3D26FC1026E07408D3C /* RoomListActivityVisibility.swift in Sources */, + 3BB19AB9CDA3034AE39C039A /* SearchBreadcrumb.swift in Sources */, 9097C3B61181B731CACEE28B /* Secrets.swift in Sources */, B6B62437B92B6CA4083AA899 /* SessionDirectories.swift in Sources */, DAF63A9CF9932CA8F6830F11 /* ShareExtensionModels.swift in Sources */, @@ -9336,6 +9343,7 @@ 0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */, 0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */, 67160204A8D362BB7D4AD259 /* Search.swift in Sources */, + E1CC93B1842137A8AB25B815 /* SearchBreadcrumb.swift in Sources */, 2A4B7856611589DD7B1B36CA /* SearchScreen.swift in Sources */, 592A5B88471676D6F50B9798 /* SearchScreenCoordinator.swift in Sources */, 3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index be5cd08b2..695beb6dd 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -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."; diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index bdeeb1d14..8076849bc 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -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) diff --git a/ElementX/Sources/Application/Settings/SearchBreadcrumb.swift b/ElementX/Sources/Application/Settings/SearchBreadcrumb.swift new file mode 100644 index 000000000..32d3f2eab --- /dev/null +++ b/ElementX/Sources/Application/Settings/SearchBreadcrumb.swift @@ -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) +} diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index cb695f817..16f99c1af 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index a2539f737..c4d09c595 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -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 diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 521b411c5..e55c2cfa6 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -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: diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index 9ef9a26f0..50bdb5086 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -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() { diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index 7e54cdfe9..a657ba111 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -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 { diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index b9544ec3a..e6c9852d4 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -12,9 +12,14 @@ import SwiftUI typealias SearchScreenViewModelType = StateStoreViewModelV2 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? private var searchModeObservationTask: Task? private var loadingObservationTask: Task? @@ -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 diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index e5f8b55da..47d9a30d1 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -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 } } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png new file mode 100644 index 000000000..fd67989ec --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:971c64a61ad518e5c1c00ac489f26c537f4f0e588c6cf261abc0d5bdb3df4eb3 +size 105354 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png new file mode 100644 index 000000000..8e81dd7ae --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41c877e709510513ce62e17efdf76ef98e047f944c08a0b77ea8e2891543e82b +size 107612 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png new file mode 100644 index 000000000..cdd0550af --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e9b2fa5718b0128127f38d10ef354b380b4f96dd1f46c5a7a047c5872acd7aa +size 59812 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png new file mode 100644 index 000000000..bd90694fe --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:170d448bdd47bd87220cd3023b5e637681f8484380ce9e1bea2aca76cf5dcfcd +size 62343 diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index 80a90eba8..4bee0469f 100644 --- a/UnitTests/Sources/SearchScreenViewModelTests.swift +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -14,6 +14,9 @@ struct SearchScreenViewModelTests { let viewModel: SearchScreenViewModelProtocol let searchService: SearchServiceProxyMock let userIndicatorController: UserIndicatorControllerMock + let appSettings: AppSettings + /// The rooms known to the client, empty until the room list loads. + let staticRoomListSubject = CurrentValueSubject<[RoomSummary], Never>([]) /// Fires with the query each time the (async, debounced) message search runs. let setQuerySubject = PassthroughSubject() /// Fires each time an indicator is submitted. @@ -39,11 +42,20 @@ struct SearchScreenViewModelTests { let clientProxy = ClientProxyMock(.init()) clientProxy.searchService = searchService + let staticRoomSummaryProvider = RoomSummaryProviderMock() + staticRoomSummaryProvider.roomListPublisher = staticRoomListSubject.asCurrentValuePublisher() + clientProxy.staticRoomSummaryProvider = staticRoomSummaryProvider + clientProxy.roomSummaryForIdentifierClosure = { [staticRoomListSubject] identifier in + staticRoomListSubject.value.first { $0.id == identifier } + } + + appSettings = AppSettings.volatile() viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), clientProxy: clientProxy, mediaProvider: MediaProviderMock(.init()), - userIndicatorController: userIndicatorController) + userIndicatorController: userIndicatorController, + appSettings: appSettings) } @Test @@ -94,6 +106,47 @@ struct SearchScreenViewModelTests { try await deferred.fulfill() } + @Test + func selectionRecordsBreadcrumbs() { + context.searchQuery = "Second" + context.send(viewAction: .selectRoom(roomID: "2")) + + #expect(appSettings.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")]) + + // Selecting the same room again from the empty state only bumps it back to the top. + context.searchQuery = "" + context.send(viewAction: .selectRoom(roomID: "2")) + + #expect(appSettings.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")]) + } + + @Test + func breadcrumbsAreDisplayed() async throws { + staticRoomListSubject.send([.mock(id: "2", name: "Second")]) + + let deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 2 } + appSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] + try await deferred.fulfill() + + #expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second", "room-2"]) + } + + /// The room list is empty when the app launches, so the rooms need resolving again once it has loaded. + @Test + func roomBreadcrumbsAreDisplayedAfterTheRoomListLoads() async throws { + var deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 1 } + appSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] + try await deferred.fulfill() + + #expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second"]) + + deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 2 } + staticRoomListSubject.send([.mock(id: "2", name: "Second")]) + try await deferred.fulfill() + + #expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second", "room-2"]) + } + @Test func cancel() async throws { let deferred = deferFulfillment(viewModel.actionsPublisher) { action in