mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
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:
@@ -378,6 +378,7 @@
|
|||||||
3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; };
|
3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; };
|
||||||
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2AF1828A5B76B7C371240FE /* StateRoomTimelineView.swift */; };
|
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2AF1828A5B76B7C371240FE /* StateRoomTimelineView.swift */; };
|
||||||
3B5AB5CF8D8163599C5BF19B /* PillViewOnBubblePreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2005B4AD70A792340E2694F1 /* PillViewOnBubblePreviews.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 */; };
|
3BEBDCB42BABFA3B456FECA7 /* MapTilerURLBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D262A26713C18BB70C82CA5 /* MapTilerURLBuilderTests.swift */; };
|
||||||
3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */; };
|
3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */; };
|
||||||
3C549A0BF39F8A854D45D9FD /* EmbeddedElementCall in Frameworks */ = {isa = PBXBuildFile; productRef = 424B603C07AEA6FCA7C8E7C9 /* EmbeddedElementCall */; };
|
3C549A0BF39F8A854D45D9FD /* EmbeddedElementCall in Frameworks */ = {isa = PBXBuildFile; productRef = 424B603C07AEA6FCA7C8E7C9 /* EmbeddedElementCall */; };
|
||||||
@@ -495,6 +496,7 @@
|
|||||||
4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; };
|
4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; };
|
||||||
4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; };
|
4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; };
|
||||||
4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.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 */; };
|
4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */; };
|
||||||
4F51C7C8131D060108B80575 /* NavigationCoordinators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79351405CD99312A182B2488 /* NavigationCoordinators.swift */; };
|
4F51C7C8131D060108B80575 /* NavigationCoordinators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79351405CD99312A182B2488 /* NavigationCoordinators.swift */; };
|
||||||
4FBC9E416582352432EBAB7F /* ClientFactoryHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3561843E139EA7CDAED0933 /* ClientFactoryHook.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 */; };
|
E184FFAD32342D3D6E2F89AA /* PinnedEventsTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D53754227CEBD06358956D7 /* PinnedEventsTimelineScreenCoordinator.swift */; };
|
||||||
E1A0752244567A25E376A2F4 /* AdvancedSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A1B80FE6E3BA72F9C748AD /* AdvancedSettingsScreenViewModel.swift */; };
|
E1A0752244567A25E376A2F4 /* AdvancedSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A1B80FE6E3BA72F9C748AD /* AdvancedSettingsScreenViewModel.swift */; };
|
||||||
E1C67E5D9E22135A8FEBBD60 /* StackedAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8558D41DD4B553A752C868A /* StackedAvatarsView.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 */; };
|
E1DE8A5422643F759385290F /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD5523BDEDB247E29228476 /* AppSettings.swift */; };
|
||||||
E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; };
|
E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; };
|
||||||
E26E702F325F0AA49B8A5CFD /* RoomPowerLevelsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.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 = "<group>"; };
|
08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||||
0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsFlowCoordinator.swift; sourceTree = "<group>"; };
|
0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsFlowCoordinator.swift; sourceTree = "<group>"; };
|
||||||
094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenCoordinator.swift; sourceTree = "<group>"; };
|
094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBreadcrumb.swift; sourceTree = "<group>"; };
|
||||||
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = "<group>"; };
|
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = "<group>"; };
|
||||||
0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentlyUsedEmoji.swift; sourceTree = "<group>"; };
|
0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequentlyUsedEmoji.swift; sourceTree = "<group>"; };
|
||||||
0A3534D37BFC074DD26F2FFE /* RoomThreadListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenModels.swift; sourceTree = "<group>"; };
|
0A3534D37BFC074DD26F2FFE /* RoomThreadListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreenModels.swift; sourceTree = "<group>"; };
|
||||||
@@ -6856,6 +6860,7 @@
|
|||||||
8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */,
|
8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */,
|
||||||
69A05E85E4872C3221C5C287 /* RemotePreference.swift */,
|
69A05E85E4872C3221C5C287 /* RemotePreference.swift */,
|
||||||
A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */,
|
A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */,
|
||||||
|
095B8ED0CA87F4A0A4858719 /* SearchBreadcrumb.swift */,
|
||||||
10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */,
|
10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */,
|
||||||
0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */,
|
0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */,
|
||||||
F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */,
|
F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */,
|
||||||
@@ -8244,6 +8249,7 @@
|
|||||||
5E73893669BB56307A44AAAD /* RoomListActivityVisibility.swift in Sources */,
|
5E73893669BB56307A44AAAD /* RoomListActivityVisibility.swift in Sources */,
|
||||||
17BC15DA08A52587466698C5 /* RoomMessageEventStringBuilder.swift in Sources */,
|
17BC15DA08A52587466698C5 /* RoomMessageEventStringBuilder.swift in Sources */,
|
||||||
06D17F7813AA931FF18FD5D0 /* SDKListener.swift in Sources */,
|
06D17F7813AA931FF18FD5D0 /* SDKListener.swift in Sources */,
|
||||||
|
4EF76B21E1128FA18F8FB7A9 /* SearchBreadcrumb.swift in Sources */,
|
||||||
329D65DD7CC0E42CAD282EF7 /* Secrets.swift in Sources */,
|
329D65DD7CC0E42CAD282EF7 /* Secrets.swift in Sources */,
|
||||||
7573D682F089205F7F1D96CF /* SessionDirectories.swift in Sources */,
|
7573D682F089205F7F1D96CF /* SessionDirectories.swift in Sources */,
|
||||||
422E8D182CA688D4565CD1E1 /* String.swift in Sources */,
|
422E8D182CA688D4565CD1E1 /* String.swift in Sources */,
|
||||||
@@ -8494,6 +8500,7 @@
|
|||||||
6F76F34F4CAD400F5015EC5C /* RemoteSettingsHook.swift in Sources */,
|
6F76F34F4CAD400F5015EC5C /* RemoteSettingsHook.swift in Sources */,
|
||||||
1C4CB9009E50E6535883D5B2 /* RestorationToken.swift in Sources */,
|
1C4CB9009E50E6535883D5B2 /* RestorationToken.swift in Sources */,
|
||||||
6885C3D26FC1026E07408D3C /* RoomListActivityVisibility.swift in Sources */,
|
6885C3D26FC1026E07408D3C /* RoomListActivityVisibility.swift in Sources */,
|
||||||
|
3BB19AB9CDA3034AE39C039A /* SearchBreadcrumb.swift in Sources */,
|
||||||
9097C3B61181B731CACEE28B /* Secrets.swift in Sources */,
|
9097C3B61181B731CACEE28B /* Secrets.swift in Sources */,
|
||||||
B6B62437B92B6CA4083AA899 /* SessionDirectories.swift in Sources */,
|
B6B62437B92B6CA4083AA899 /* SessionDirectories.swift in Sources */,
|
||||||
DAF63A9CF9932CA8F6830F11 /* ShareExtensionModels.swift in Sources */,
|
DAF63A9CF9932CA8F6830F11 /* ShareExtensionModels.swift in Sources */,
|
||||||
@@ -9336,6 +9343,7 @@
|
|||||||
0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */,
|
0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */,
|
||||||
0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */,
|
0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */,
|
||||||
67160204A8D362BB7D4AD259 /* Search.swift in Sources */,
|
67160204A8D362BB7D4AD259 /* Search.swift in Sources */,
|
||||||
|
E1CC93B1842137A8AB25B815 /* SearchBreadcrumb.swift in Sources */,
|
||||||
2A4B7856611589DD7B1B36CA /* SearchScreen.swift in Sources */,
|
2A4B7856611589DD7B1B36CA /* SearchScreen.swift in Sources */,
|
||||||
592A5B88471676D6F50B9798 /* SearchScreenCoordinator.swift in Sources */,
|
592A5B88471676D6F50B9798 /* SearchScreenCoordinator.swift in Sources */,
|
||||||
3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */,
|
3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"screen_search_empty_state_message" = "Search for chats and messages";
|
"screen_search_empty_state_message" = "Search for chats and messages";
|
||||||
"screen_search_empty_state_title" = "Start searching...";
|
"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_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_chats" = "Chats";
|
||||||
"screen_search_tab_messages" = "Messages";
|
"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.";
|
"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() {
|
func resetSessionSpecificSettings() {
|
||||||
MXLog.warning("Resetting the user session specific AppSettings.")
|
MXLog.warning("Resetting the user session specific AppSettings.")
|
||||||
resetHasRunIdentityConfirmationOnboarding()
|
resetHasRunIdentityConfirmationOnboarding()
|
||||||
|
resetSearchBreadcrumbs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hooks
|
// MARK: - Hooks
|
||||||
@@ -360,6 +361,12 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var roomListNotificationCountEnabled: Bool
|
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
|
// MARK: - Room Screen
|
||||||
|
|
||||||
@UserPreference(defaultValue: AppBuildType.current == .debug)
|
@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,
|
let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
|
||||||
clientProxy: flowParameters.userSession.clientProxy,
|
clientProxy: flowParameters.userSession.clientProxy,
|
||||||
mediaProvider: flowParameters.userSession.mediaProvider,
|
mediaProvider: flowParameters.userSession.mediaProvider,
|
||||||
userIndicatorController: flowParameters.userIndicatorController))
|
userIndicatorController: flowParameters.userIndicatorController,
|
||||||
|
appSettings: flowParameters.appSettings))
|
||||||
let searchStackCoordinator = NavigationStackCoordinator()
|
let searchStackCoordinator = NavigationStackCoordinator()
|
||||||
searchStackCoordinator.setRootCoordinator(searchCoordinator)
|
searchStackCoordinator.setRootCoordinator(searchCoordinator)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ internal nonisolated enum UntranslatedL10n {
|
|||||||
internal static func screenSearchNoResultsMessage(_ p1: Any) -> String {
|
internal static func screenSearchNoResultsMessage(_ p1: Any) -> String {
|
||||||
return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1))
|
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
|
/// Chats
|
||||||
internal static var screenSearchTabChats: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_chats") }
|
internal static var screenSearchTabChats: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_chats") }
|
||||||
/// Messages
|
/// Messages
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ enum RoomAvatarSizeOnScreen {
|
|||||||
case leaveSpace
|
case leaveSpace
|
||||||
case messageForwarding
|
case messageForwarding
|
||||||
case search
|
case search
|
||||||
|
case searchHistory
|
||||||
case roomSelection
|
case roomSelection
|
||||||
case details
|
case details
|
||||||
case editRoomDetails
|
case editRoomDetails
|
||||||
@@ -153,7 +154,7 @@ enum RoomAvatarSizeOnScreen {
|
|||||||
switch self {
|
switch self {
|
||||||
case .timeline, .leaveSpace, .notificationSettings, .roomDirectorySearch,
|
case .timeline, .leaveSpace, .notificationSettings, .roomDirectorySearch,
|
||||||
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
|
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
|
||||||
.spaceFilters:
|
.spaceFilters, .searchHistory:
|
||||||
32
|
32
|
||||||
case .messageForwarding, .roomSelection,
|
case .messageForwarding, .roomSelection,
|
||||||
.spaceAddRooms:
|
.spaceAddRooms:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct SearchScreenCoordinatorParameters {
|
|||||||
let clientProxy: ClientProxyProtocol
|
let clientProxy: ClientProxyProtocol
|
||||||
let mediaProvider: MediaProviderProtocol
|
let mediaProvider: MediaProviderProtocol
|
||||||
let userIndicatorController: UserIndicatorControllerProtocol
|
let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
|
let appSettings: AppSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SearchScreenCoordinatorAction {
|
enum SearchScreenCoordinatorAction {
|
||||||
@@ -34,7 +35,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol {
|
|||||||
viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
|
viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
|
||||||
clientProxy: parameters.clientProxy,
|
clientProxy: parameters.clientProxy,
|
||||||
mediaProvider: parameters.mediaProvider,
|
mediaProvider: parameters.mediaProvider,
|
||||||
userIndicatorController: parameters.userIndicatorController)
|
userIndicatorController: parameters.userIndicatorController,
|
||||||
|
appSettings: parameters.appSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ enum SearchScreenMode: CaseIterable, Identifiable {
|
|||||||
struct SearchScreenViewState: BindableState {
|
struct SearchScreenViewState: BindableState {
|
||||||
var rooms = [SearchScreenRoom]()
|
var rooms = [SearchScreenRoom]()
|
||||||
var messages = [SearchScreenMessage]()
|
var messages = [SearchScreenMessage]()
|
||||||
|
var breadcrumbs = [SearchScreenBreadcrumb]()
|
||||||
var isLoadingRooms = false
|
var isLoadingRooms = false
|
||||||
var isLoadingMessages = false
|
var isLoadingMessages = false
|
||||||
var bindings: SearchScreenViewStateBindings
|
var bindings: SearchScreenViewStateBindings
|
||||||
@@ -59,6 +60,32 @@ struct SearchScreenRoom: Identifiable, Equatable {
|
|||||||
let title: String
|
let title: String
|
||||||
let description: String
|
let description: String
|
||||||
let avatar: RoomAvatar
|
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 {
|
struct SearchScreenMessage: Identifiable, Equatable {
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ import SwiftUI
|
|||||||
typealias SearchScreenViewModelType = StateStoreViewModelV2<SearchScreenViewState, SearchScreenViewAction>
|
typealias SearchScreenViewModelType = StateStoreViewModelV2<SearchScreenViewState, SearchScreenViewAction>
|
||||||
|
|
||||||
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
|
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
|
||||||
|
/// The number of entries kept in the search history.
|
||||||
|
private static let maximumBreadcrumbCount = 25
|
||||||
|
|
||||||
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||||
|
private let clientProxy: ClientProxyProtocol
|
||||||
private let searchService: SearchServiceProxyProtocol
|
private let searchService: SearchServiceProxyProtocol
|
||||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
|
private let appSettings: AppSettings
|
||||||
private var searchQueryObservationTask: Task<Void, Never>?
|
private var searchQueryObservationTask: Task<Void, Never>?
|
||||||
private var searchModeObservationTask: Task<Void, Never>?
|
private var searchModeObservationTask: Task<Void, Never>?
|
||||||
private var loadingObservationTask: Task<Void, Never>?
|
private var loadingObservationTask: Task<Void, Never>?
|
||||||
@@ -31,11 +36,14 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
clientProxy: ClientProxyProtocol,
|
clientProxy: ClientProxyProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
userIndicatorController: UserIndicatorControllerProtocol,
|
userIndicatorController: UserIndicatorControllerProtocol,
|
||||||
|
appSettings: AppSettings,
|
||||||
initialSearchQuery: String = "",
|
initialSearchQuery: String = "",
|
||||||
initialSearchMode: SearchScreenMode = .rooms) {
|
initialSearchMode: SearchScreenMode = .rooms) {
|
||||||
self.roomSummaryProvider = roomSummaryProvider
|
self.roomSummaryProvider = roomSummaryProvider
|
||||||
|
self.clientProxy = clientProxy
|
||||||
searchService = clientProxy.searchService
|
searchService = clientProxy.searchService
|
||||||
self.userIndicatorController = userIndicatorController
|
self.userIndicatorController = userIndicatorController
|
||||||
|
self.appSettings = appSettings
|
||||||
|
|
||||||
super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)),
|
super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)),
|
||||||
mediaProvider: mediaProvider)
|
mediaProvider: mediaProvider)
|
||||||
@@ -64,6 +72,18 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
searchService.paginationStatePublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] paginationState in
|
.sink { [weak self] paginationState in
|
||||||
@@ -98,6 +118,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
|
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
|
||||||
|
state.breadcrumbs = makeBreadcrumbs(from: appSettings.searchBreadcrumbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
isolated deinit {
|
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.
|
// Re-apply ours on every appearance to keep the displayed results in sync with the query.
|
||||||
updateFilter(for: state.bindings.searchQuery, forced: true)
|
updateFilter(for: state.bindings.searchQuery, forced: true)
|
||||||
case .selectRoom(let roomID):
|
case .selectRoom(let roomID):
|
||||||
|
recordBreadcrumbs(roomID: roomID)
|
||||||
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil))
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil))
|
||||||
case .selectMessage(let roomID, let eventID):
|
case .selectMessage(let roomID, let eventID):
|
||||||
|
recordBreadcrumbs(roomID: roomID)
|
||||||
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
|
||||||
case .reachedTop:
|
case .reachedTop:
|
||||||
if state.bindings.searchMode == .rooms {
|
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) {
|
private func setActiveTabLoading(_ isLoading: Bool) {
|
||||||
switch state.bindings.searchMode {
|
switch state.bindings.searchMode {
|
||||||
case .rooms:
|
case .rooms:
|
||||||
@@ -186,18 +236,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
private func updateRooms(with summaries: [RoomSummary]) {
|
private func updateRooms(with summaries: [RoomSummary]) {
|
||||||
// The list has caught up with the current filter, so we're no longer waiting on results.
|
// The list has caught up with the current filter, so we're no longer waiting on results.
|
||||||
state.isLoadingRooms = false
|
state.isLoadingRooms = false
|
||||||
state.rooms = summaries.map { summary in
|
state.rooms = summaries.map(SearchScreenRoom.init)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The actual range values don't matter as long as they contain the lower
|
/// 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.
|
/// The selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise.
|
||||||
@State private var isHardwareKeyboardConnected = false
|
@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] {
|
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 .rooms: context.viewState.rooms.map(\.id)
|
||||||
case .messages: context.viewState.messages.map(\.id)
|
case .messages: context.viewState.messages.map(\.id)
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,8 @@ struct SearchScreen: View {
|
|||||||
iconStyle: .defaultSolid)
|
iconStyle: .defaultSolid)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(40)
|
.padding(40)
|
||||||
|
} else if !context.viewState.breadcrumbs.isEmpty {
|
||||||
|
breadcrumbsList
|
||||||
} else {
|
} else {
|
||||||
TitleAndIcon(title: UntranslatedL10n.screenSearchEmptyStateTitle,
|
TitleAndIcon(title: UntranslatedL10n.screenSearchEmptyStateTitle,
|
||||||
subtitle: UntranslatedL10n.screenSearchEmptyStateMessage,
|
subtitle: UntranslatedL10n.screenSearchEmptyStateMessage,
|
||||||
@@ -176,6 +183,27 @@ struct SearchScreen: View {
|
|||||||
.compoundList(.plain)
|
.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.
|
/// Hidden buttons that switch tabs via ⌘1/⌘2, the common shortcut for jumping to tab N.
|
||||||
private var tabShortcuts: some View {
|
private var tabShortcuts: some View {
|
||||||
ForEach(Array(SearchScreenMode.allCases.enumerated()), id: \.element) { index, mode in
|
ForEach(Array(SearchScreenMode.allCases.enumerated()), id: \.element) { index, mode in
|
||||||
@@ -201,6 +229,19 @@ struct SearchScreen: View {
|
|||||||
|
|
||||||
private func selectCurrent() {
|
private func selectCurrent() {
|
||||||
guard let selectedID else { return }
|
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 {
|
switch context.viewState.bindings.searchMode {
|
||||||
case .rooms:
|
case .rooms:
|
||||||
context.send(viewAction: .selectRoom(roomID: selectedID))
|
context.send(viewAction: .selectRoom(roomID: selectedID))
|
||||||
@@ -230,6 +271,7 @@ private struct SearchScreenRoomCell: View {
|
|||||||
let room: SearchScreenRoom
|
let room: SearchScreenRoom
|
||||||
let context: SearchScreenViewModel.Context
|
let context: SearchScreenViewModel.Context
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
var avatarSize: RoomAvatarSizeOnScreen = .search
|
||||||
|
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
|
||||||
@@ -268,7 +310,7 @@ private struct SearchScreenRoomCell: View {
|
|||||||
private var avatar: some View {
|
private var avatar: some View {
|
||||||
if dynamicTypeSize < .accessibility3 {
|
if dynamicTypeSize < .accessibility3 {
|
||||||
RoomAvatarImage(avatar: room.avatar,
|
RoomAvatarImage(avatar: room.avatar,
|
||||||
avatarSize: .room(on: .search),
|
avatarSize: .room(on: avatarSize),
|
||||||
mediaProvider: context.mediaProvider)
|
mediaProvider: context.mediaProvider)
|
||||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||||
.accessibilityHidden(true)
|
.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 {
|
private struct SearchScreenMessageCell: View {
|
||||||
let message: SearchScreenMessage
|
let message: SearchScreenMessage
|
||||||
let context: SearchScreenViewModel.Context
|
let context: SearchScreenViewModel.Context
|
||||||
@@ -471,36 +549,56 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
||||||
clientProxy: makeClientProxy(),
|
clientProxy: makeClientProxy(),
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock())
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
|
appSettings: AppSettings.volatile())
|
||||||
static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
||||||
clientProxy: makeClientProxy(),
|
clientProxy: makeClientProxy(),
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
|
appSettings: AppSettings.volatile(),
|
||||||
initialSearchQuery: "John Doe")
|
initialSearchQuery: "John Doe")
|
||||||
static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
||||||
clientProxy: makeClientProxy(),
|
clientProxy: makeClientProxy(),
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
|
appSettings: AppSettings.volatile(),
|
||||||
initialSearchQuery: "Foundation")
|
initialSearchQuery: "Foundation")
|
||||||
static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
||||||
clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)),
|
clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)),
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
|
appSettings: AppSettings.volatile(),
|
||||||
initialSearchQuery: "Foundation",
|
initialSearchQuery: "Foundation",
|
||||||
initialSearchMode: .messages)
|
initialSearchMode: .messages)
|
||||||
static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
||||||
clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)),
|
clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)),
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
|
appSettings: AppSettings.volatile(),
|
||||||
initialSearchQuery: "Foundation",
|
initialSearchQuery: "Foundation",
|
||||||
initialSearchMode: .messages)
|
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 {
|
static var previews: some View {
|
||||||
ElementNavigationStack {
|
ElementNavigationStack {
|
||||||
SearchScreen(context: emptyViewModel.context)
|
SearchScreen(context: emptyViewModel.context)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Empty")
|
.previewDisplayName("Empty")
|
||||||
|
|
||||||
|
ElementNavigationStack {
|
||||||
|
SearchScreen(context: breadcrumbsViewModel.context)
|
||||||
|
}
|
||||||
|
.previewDisplayName("Recent searches")
|
||||||
|
|
||||||
ElementNavigationStack {
|
ElementNavigationStack {
|
||||||
SearchScreen(context: noResultsViewModel.context)
|
SearchScreen(context: noResultsViewModel.context)
|
||||||
}
|
}
|
||||||
@@ -530,13 +628,22 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
return mock
|
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 {
|
private static func makeClientProxy(searchService: SearchServiceProxyMock = makeSearchService()) -> ClientProxyMock {
|
||||||
let mock = ClientProxyMock(.init(userID: "@alice:matrix.org"))
|
let mock = ClientProxyMock(.init(userID: "@alice:matrix.org"))
|
||||||
mock.searchService = searchService
|
mock.searchService = searchService
|
||||||
let names: [String: String] = ["!room1:matrix.org": "Alice", "!room2:matrix.org": "Bob", "!room3:matrix.org": "Coline",
|
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",
|
"!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"]
|
"!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
|
return mock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:971c64a61ad518e5c1c00ac489f26c537f4f0e588c6cf261abc0d5bdb3df4eb3
|
||||||
|
size 105354
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:41c877e709510513ce62e17efdf76ef98e047f944c08a0b77ea8e2891543e82b
|
||||||
|
size 107612
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2e9b2fa5718b0128127f38d10ef354b380b4f96dd1f46c5a7a047c5872acd7aa
|
||||||
|
size 59812
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:170d448bdd47bd87220cd3023b5e637681f8484380ce9e1bea2aca76cf5dcfcd
|
||||||
|
size 62343
|
||||||
@@ -14,6 +14,9 @@ struct SearchScreenViewModelTests {
|
|||||||
let viewModel: SearchScreenViewModelProtocol
|
let viewModel: SearchScreenViewModelProtocol
|
||||||
let searchService: SearchServiceProxyMock
|
let searchService: SearchServiceProxyMock
|
||||||
let userIndicatorController: UserIndicatorControllerMock
|
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.
|
/// Fires with the query each time the (async, debounced) message search runs.
|
||||||
let setQuerySubject = PassthroughSubject<String, Never>()
|
let setQuerySubject = PassthroughSubject<String, Never>()
|
||||||
/// Fires each time an indicator is submitted.
|
/// Fires each time an indicator is submitted.
|
||||||
@@ -39,11 +42,20 @@ struct SearchScreenViewModelTests {
|
|||||||
|
|
||||||
let clientProxy = ClientProxyMock(.init())
|
let clientProxy = ClientProxyMock(.init())
|
||||||
clientProxy.searchService = searchService
|
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))),
|
viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
||||||
clientProxy: clientProxy,
|
clientProxy: clientProxy,
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
userIndicatorController: userIndicatorController)
|
userIndicatorController: userIndicatorController,
|
||||||
|
appSettings: appSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -94,6 +106,47 @@ struct SearchScreenViewModelTests {
|
|||||||
try await deferred.fulfill()
|
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
|
@Test
|
||||||
func cancel() async throws {
|
func cancel() async throws {
|
||||||
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
|
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||||
|
|||||||
Reference in New Issue
Block a user