From 7f1b6086aef573f75a5b3ff18986572d0ce14761 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 19 Jun 2026 13:50:17 +0300 Subject: [PATCH] Search tab (#5754) * Add a developer flag for the search tab Introduces a globalSearchEnabled user preference (off by default) with a toggle in Developer Options, used to gate the new search experience. * Support search role tabs in the tab coordinator Migrates the NavigationTabCoordinator to SwiftUI's value-based Tab API and adds an isSearch option so a tab can adopt the iOS 26 .search role, which detaches it into the trailing search button in the tab bar. * Moves room search to a new screen Adds a new screen that filters the alternate room summary provider as you type, shows an empty state, and lists matching rooms with their matrix identifier. Supports hardware keyboard selection (arrow keys to move through results, return to open) by reaching into the system search field. * Present search in a dedicated tab Adds the search tab to the chats flow on iOS 26 when the flag is enabled, rendered as the detached .search role button. The Cmd+K shortcut now selects this tab, and the home screen's inline search is hidden while the tab is available. * Remove the old global search screen The dedicated search tab replaces the Cmd+K floating window, so the GlobalSearchScreen, its window plumbing in the WindowManager, and its presentation in the chats flow are all removed. --- .../Sources/GeneratedAccessibilityTests.swift | 12 +- ElementX.xcodeproj/project.pbxproj | 84 +++--- .../en.lproj/Untranslated.strings | 4 + .../Sources/Application/Application.swift | 2 +- .../Application/Navigation/AppRoutes.swift | 4 +- .../Navigation/NavigationTabCoordinator.swift | 28 +- .../Application/Settings/AppSettings.swift | 6 + .../Application/Windowing/WindowManager.swift | 42 +-- .../Windowing/WindowManagerProtocol.swift | 7 - .../ChatsTabFlowCoordinator.swift | 46 +-- .../EncryptionSettingsFlowCoordinator.swift | 2 +- .../RoomFlowCoordinator.swift | 2 +- .../RoomMembersFlowCoordinator.swift | 2 +- .../UserSessionFlowCoordinator.swift | 46 ++- .../Generated/Strings+Untranslated.swift | 10 + .../Mocks/Generated/GeneratedMocks.swift | 35 --- ElementX/Sources/Other/Avatars.swift | 4 +- .../TestablePreviewsDictionary.swift | 3 +- .../GlobalSearchScreenCoordinator.swift | 58 ---- .../GlobalSearchScreenModels.swift | 37 --- .../GlobalSearchScreenViewModel.swift | 101 ------- .../GlobalSearchScreenViewModelProtocol.swift | 16 -- .../View/GlobalSearchScreen.swift | 215 -------------- .../View/GlobalSearchScreenCell.swift | 54 ---- .../Screens/HomeScreen/HomeScreenModels.swift | 3 + .../HomeScreen/HomeScreenViewModel.swift | 4 + .../HomeScreen/View/HomeScreenContent.swift | 21 +- .../SearchScreenCoordinator.swift | 49 ++++ .../SearchScreen/SearchScreenModels.swift | 39 +++ .../SearchScreen/SearchScreenViewModel.swift | 115 ++++++++ .../SearchScreenViewModelProtocol.swift | 14 + .../SearchScreen/View/SearchScreen.swift | 263 ++++++++++++++++++ .../DeveloperOptionsScreenModels.swift | 2 + .../View/DeveloperOptionsScreen.swift | 5 + .../Room/RoomSummary/RoomSummary.swift | 2 +- .../Sources/GeneratedPreviewTests.swift | 21 +- .../globalSearchScreen.iPad-en-GB-0.png | 3 - .../globalSearchScreen.iPad-pseudo-0.png | 3 - .../globalSearchScreen.iPhone-en-GB-0.png | 3 - .../globalSearchScreen.iPhone-pseudo-0.png | 3 - ...globalSearchScreenListRow.iPad-en-GB-0.png | 3 - ...lobalSearchScreenListRow.iPad-pseudo-0.png | 3 - ...obalSearchScreenListRow.iPhone-en-GB-0.png | 3 - ...balSearchScreenListRow.iPhone-pseudo-0.png | 3 - .../searchScreen.Empty-iPad-en-GB.png | 3 + .../searchScreen.Empty-iPad-pseudo.png | 3 + .../searchScreen.Empty-iPhone-en-GB.png | 3 + .../searchScreen.Empty-iPhone-pseudo.png | 3 + .../searchScreen.Loaded-iPad-en-GB.png | 3 + .../searchScreen.Loaded-iPad-pseudo.png | 3 + .../searchScreen.Loaded-iPhone-en-GB.png | 3 + .../searchScreen.Loaded-iPhone-pseudo.png | 3 + .../searchScreen.No-results-iPad-en-GB.png | 3 + .../searchScreen.No-results-iPad-pseudo.png | 3 + .../searchScreen.No-results-iPhone-en-GB.png | 3 + .../searchScreen.No-results-iPhone-pseudo.png | 3 + .../GlobalSearchScreenViewModelTests.swift | 50 ---- .../Sources/SearchScreenViewModelTests.swift | 43 +++ 58 files changed, 731 insertions(+), 780 deletions(-) delete mode 100644 ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenCoordinator.swift delete mode 100644 ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenModels.swift delete mode 100644 ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift delete mode 100644 ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModelProtocol.swift delete mode 100644 ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreen.swift delete mode 100644 ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreenCell.swift create mode 100644 ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift create mode 100644 ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift create mode 100644 ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift create mode 100644 ElementX/Sources/Screens/SearchScreen/SearchScreenViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-en-GB-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-pseudo-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-en-GB-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-pseudo-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-en-GB-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-pseudo-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-en-GB-0.png delete mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png delete mode 100644 UnitTests/Sources/GlobalSearchScreenViewModelTests.swift create mode 100644 UnitTests/Sources/SearchScreenViewModelTests.swift diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index d5d287079..ae2774f42 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -179,14 +179,6 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "FullscreenDialog_Previews") } - func testGlobalSearchScreenListRow() async throws { - try await performAccessibilityAudit(named: "GlobalSearchScreenListRow_Previews") - } - - func testGlobalSearchScreen() async throws { - try await performAccessibilityAudit(named: "GlobalSearchScreen_Previews") - } - func testHighlightedTimelineItemModifier() async throws { try await performAccessibilityAudit(named: "HighlightedTimelineItemModifier_Previews") } @@ -611,6 +603,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "SFNumberedListView_Previews") } + func testSearchScreen() async throws { + try await performAccessibilityAudit(named: "SearchScreen_Previews") + } + func testSecureBackupKeyBackupScreen() async throws { try await performAccessibilityAudit(named: "SecureBackupKeyBackupScreen_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a6ae2f16d..8a4318286 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ 298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; }; 29E411E37D3FBF3899B3ED69 /* UserDefaultsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */; }; 29EE1791E0AFA1ABB7F23D2F /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 1BCD21310B997A6837B854D6 /* GZIP */; }; + 2A4B7856611589DD7B1B36CA /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79A78330B9329E3A99E4105 /* SearchScreen.swift */; }; 2A4F8B76E1F99DE95D5867E6 /* ClassicAppManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633BAD3D9BB44B2AED7CBB93 /* ClassicAppManagerMock.swift */; }; 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */; }; 2A61D2B4A225332CECA3B937 /* ReportRoomScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20403084A320D588ACED200 /* ReportRoomScreenViewModelProtocol.swift */; }; @@ -355,6 +356,7 @@ 39A987B3E41B976D1DF944C6 /* CallScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */; }; 39DFC4B9EB6A8757210BDEC6 /* RoomSelectionScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DD2A058F3566FEEBA1D11B3 /* RoomSelectionScreenViewModelProtocol.swift */; }; 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; }; + 3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD63A653BCA2C8112654B19 /* SearchScreenModels.swift */; }; 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */; }; 3A2640C52505BAB3D983A92A /* RemotePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69A05E85E4872C3221C5C287 /* RemotePreference.swift */; }; 3A64A93A651A3CB8774ADE8E /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 3FE40E79C36E7903121E6E3B /* SwiftOGG */; }; @@ -505,6 +507,7 @@ 53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */; }; 5415236FA271AF7885D4995E /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3AB32690C6CE5C62A86D6FA /* NotificationItemProxy.swift */; }; 5470E62F65AE1803BBF3D528 /* CXProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E1BAA7232081635662A83F /* CXProviderMock.swift */; }; + 54804A7D8298F7E8ACAFDD76 /* SearchScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44C70BD39FC34A9FD2467540 /* SearchScreenViewModelProtocol.swift */; }; 54A9F957307CC55242564CE5 /* MapTilerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3A7F76FE12E274D2ED8DF4 /* MapTilerSettings.swift */; }; 54AE8860D668AFD96E7E177B /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 54FDA3625AACBD9E438D084D /* BlurEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07934EF08BB39353E4A94272 /* BlurEffectView.swift */; }; @@ -514,7 +517,6 @@ 558E2673B04FDD06A1A12DD3 /* LogViewerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7463464054DDF194C54F0B04 /* LogViewerScreenViewModelProtocol.swift */; }; 558F37B1A8F2C4CC9B1ACEDA /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = CAA3B9DF998B397C9EE64E8B /* Collections */; }; 55CDD3968D95D1A820B5491E /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */; }; - 55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38354164AF59C5006CD05878 /* GlobalSearchScreenViewModel.swift */; }; 55DF6DEEF2CEEF40F84B53B0 /* VoiceMessageRoomPlaybackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3B41C36800DD4558D7BDA7 /* VoiceMessageRoomPlaybackView.swift */; }; 5618ED25F092DF5712003829 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933B074F006F8E930DB98B4E /* TimelineMediaFrame.swift */; }; @@ -535,6 +537,7 @@ 5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */; }; 5897A59DDBD3592282092223 /* MediaSourceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49B9785E3AD7D1C15A29F2F /* MediaSourceProxy.swift */; }; 58F357A9D130A654ABCB1638 /* RageshakeConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */; }; + 592A5B88471676D6F50B9798 /* SearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */; }; 5992EF10AA157EBD97D88910 /* AudioRecorderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6569593FA36B22259E806A67 /* AudioRecorderState.swift */; }; 59C41313AED7566C3AC51163 /* RoomSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */; }; 59F940FCBE6BC343AECEF75E /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2245243369B99216C7D84E /* ImageCache.swift */; }; @@ -649,6 +652,7 @@ 6B3AB95A6993646A5081BFF9 /* TestablePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43F773904F87FF5ADFE4DD1 /* TestablePreview.swift */; }; 6B61F5B27412ED4BC2F9769C /* test_audio.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 66B96842BF5F8ACA1AC84C55 /* test_audio.mp3 */; }; 6B80C24A52411EAF10E06E96 /* SecurityAndPrivacyScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBADF8010C813D905C172CE /* SecurityAndPrivacyScreenModels.swift */; }; + 6B8F03A85E83755D98DF0443 /* SearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4724D5CB16CFA5853EF8F9 /* SearchScreenViewModel.swift */; }; 6BA9214F31A24C7A84287A56 /* SentryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337AD37403015231CAE3E80B /* SentryEvent.swift */; }; 6BAE34CFA9821709CFE61E50 /* DeveloperOptionsScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F60FDB3AEFC60830BD289FE /* DeveloperOptionsScreenHook.swift */; }; 6BEB10499149444909EBE42F /* TrackedUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */; }; @@ -761,7 +765,6 @@ 7C9BDF1FC7BD46C4676536AB /* AuthenticationStartScreenBackgroundImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682BC7BAF0EFEF512A8C5140 /* AuthenticationStartScreenBackgroundImage.swift */; }; 7CD16990BA843BE9ED639129 /* ImageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */; }; 7D249465ED00988EEEC14E05 /* JoinedRoomProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */; }; - 7D261B5119E78CC8E771CA15 /* GlobalSearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */; }; 7D58B4F46CAA9A7C3E4C6A30 /* UserDetailsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */; }; 7D6DC832DE7A3DE874E2E9BC /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = BB111AE9D390233CDD2C7FD5 /* MatrixRustSDK */; }; 7DCFC31B49BDD2BC32184E58 /* TimelineControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0D5AE752AF87DA0A920812 /* TimelineControllerFactory.swift */; }; @@ -968,7 +971,6 @@ 9EE71509E6E7519A2B2388B3 /* KnockRequestsListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD9C9A31D9AB3B6D8128E69 /* KnockRequestsListScreenModels.swift */; }; 9EF72884B74BC39B01640098 /* BugReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DF257F5D968E5DD719583C /* BugReportTests.swift */; }; 9F11B9F347F9E2D236799FB3 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; - 9F11E743EA01482E78A438B0 /* GlobalSearchScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */; }; 9F8BEA86540D8980BDD7C176 /* AuthenticationStartScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3AB4D399D39FBD78119011 /* AuthenticationStartScreenModels.swift */; }; 9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */; }; 9FBE1FB20171012260A32492 /* TimelineSenderAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D53FCCE44F96E0BC411A6CF0 /* TimelineSenderAvatarView.swift */; }; @@ -1280,7 +1282,6 @@ D304343515CE0464C5089537 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD5523BDEDB247E29228476 /* AppSettings.swift */; }; D31B34B3902BC597593F3ABB /* preview_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 200626E8353AB2729444F991 /* preview_image.jpg */; }; D33AC79A50DFC26D2498DD28 /* FileRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */; }; - D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0D98D372B17EAE9AA999 /* GlobalSearchScreenModels.swift */; }; D38E59C48BE5499A48D12031 /* CreateRoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AC8FCE224D4185F28636FF /* CreateRoomScreenCoordinator.swift */; }; D3ED5692672892F9F1E7375A /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; D3FD96913D2B1AAA3149DAC7 /* CreateRoomViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */; }; @@ -1298,6 +1299,7 @@ D5FE90A6AF5FD5AE91BD37C7 /* NotificationSettingsEditScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780258F1B9D15E30549FF4BE /* NotificationSettingsEditScreenViewModel.swift */; }; D6152E21036B88C44ECB22E7 /* EncryptionResetPasswordScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303D9438EFB481F57A366E82 /* EncryptionResetPasswordScreenViewModel.swift */; }; D63974A88CF2BC721F109C77 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = DCA3C4A997AD28E6918D4CE5 /* Compound */; }; + D69CCEC785E6C819551B402F /* SearchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */; }; D6DE764B17FB4A9A12C33BF4 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */; }; D75C591C44C2510FDFA9C3D8 /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = AFAD718C6621B94813B960B4 /* Dynamic */; }; D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; @@ -1357,7 +1359,6 @@ E313BDD2B8813144139B2E00 /* UserDiscoveryServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0287793F11C480E242B03DF5 /* UserDiscoveryServiceTest.swift */; }; E323A54F317604BDD6968D79 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; E3291AD16D7A5CB14781819C /* UserNotificationCenterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45D8149FDDA0315CDC553B4B /* UserNotificationCenterProtocol.swift */; }; - E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B788615712FED326F73D3F83 /* GlobalSearchScreenViewModelProtocol.swift */; }; E32E71A1AF5E5E69E8363B26 /* OAuthAuthenticationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA11EF88B2848B12FA6FA36 /* OAuthAuthenticationPresenter.swift */; }; E3AC72E3E58F364EF15C1CC7 /* NotificationSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514363244AE7D68080D44C6F /* NotificationSettingsScreenViewModelTests.swift */; }; E3CA565A4B9704F191B191F0 /* JoinedRoomSize+MemberCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF9AEA706926DD0DA2B954C /* JoinedRoomSize+MemberCount.swift */; }; @@ -1429,7 +1430,6 @@ EE17B7154DCA50677D931A94 /* NavigationTabCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B67DC84B42D86035FCFF6F8 /* NavigationTabCoordinatorTests.swift */; }; EE4E2C1922BBF5169E213555 /* PillAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B53D6C5C0D14B04D3AB3F6E /* PillAttachmentViewProvider.swift */; }; EE4F6D6F54B8F09F10CA66BE /* ClassicAppAES.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384A744714571BAF138C6B86 /* ClassicAppAES.swift */; }; - EE56238683BC3ECA9BA00684 /* GlobalSearchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */; }; EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; }; EEB9C1555C63B93CA9C372C2 /* EmojiPickerScreenHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B5E29E9A22F45534FBD5B58 /* EmojiPickerScreenHeaderView.swift */; }; EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; }; @@ -1449,7 +1449,6 @@ F08F7BC07CA9AEF5CD157918 /* Snapshotting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF17EA323AD0205A6AB621AA /* Snapshotting.swift */; }; F0A027BB2369606DBDE3BDAD /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 32B8F4CD937AA9C1F8FC3CBC /* KeychainAccess */; }; F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */; }; - F0DACC95F24128A54CD537E4 /* GlobalSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B8177BD2AF45A286F5DA31 /* GlobalSearchScreen.swift */; }; F0F82C3C848C865C3098AA52 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = BA93CD75CCE486660C9040BD /* Collections */; }; F103924DED414ADFE398CE99 /* RoomPollsHistoryScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */; }; F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */; }; @@ -1714,6 +1713,7 @@ 0CBB0B65E5C1686700EF1276 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/SAS.strings; sourceTree = ""; }; 0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsProtocol.swift; sourceTree = ""; }; 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelProtocol.swift; sourceTree = ""; }; + 0CD63A653BCA2C8112654B19 /* SearchScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenModels.swift; sourceTree = ""; }; 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenModels.swift; sourceTree = ""; }; 0D432771B389EC486E7F90C6 /* CreateRoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenModels.swift; sourceTree = ""; }; 0D879FC4E881E748BB9B34DC /* RoomChangePermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenCoordinator.swift; sourceTree = ""; }; @@ -1808,6 +1808,7 @@ 1C21A715237F2B6D6E80998C /* SecureBackupControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupControllerProtocol.swift; sourceTree = ""; }; 1C25B6EBEB414431187D73B7 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; 1C3A7F76FE12E274D2ED8DF4 /* MapTilerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerSettings.swift; sourceTree = ""; }; + 1C4724D5CB16CFA5853EF8F9 /* SearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModel.swift; sourceTree = ""; }; 1C78111573987B1D79ED0868 /* LinkMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkMetadataProvider.swift; sourceTree = ""; }; 1C782FCBBCC9A0CD30453C50 /* ChatsTabFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsTabFlowCoordinator.swift; sourceTree = ""; }; 1C7F63EB1525E697CAEB002B /* BlankFormCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlankFormCoordinator.swift; sourceTree = ""; }; @@ -1850,7 +1851,6 @@ 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModelProtocol.swift; sourceTree = ""; }; 227AC5D71A4CE43512062243 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 22B384D54464FA39C6C7F6E7 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = ""; }; - 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenCell.swift; sourceTree = ""; }; 233D5F7E5E9F49ABF3413291 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hr; path = hr.lproj/Localizable.stringsdict; sourceTree = ""; }; 2355398E4A55DA5A89128AD1 /* EncryptionKeyProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyProvider.swift; sourceTree = ""; }; 2363DB6162BBCC511B67B527 /* AccessibilityTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = AccessibilityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1862,7 +1862,6 @@ 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerTests.swift; sourceTree = ""; }; 2441E2424E78A40FC95DBA76 /* AudioRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderTests.swift; sourceTree = ""; }; 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyMock.swift; sourceTree = ""; }; - 24B8177BD2AF45A286F5DA31 /* GlobalSearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreen.swift; sourceTree = ""; }; 24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModel.swift; sourceTree = ""; }; 24E637CF570711FB5FD63DEA /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 24EC819497BB5F8C4998D760 /* RoomListFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterView.swift; sourceTree = ""; }; @@ -1967,7 +1966,6 @@ 37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreen.swift; sourceTree = ""; }; 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyMock.swift; sourceTree = ""; }; 38345442415E07A931197C55 /* AppLockScreenPINKeypad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenPINKeypad.swift; sourceTree = ""; }; - 38354164AF59C5006CD05878 /* GlobalSearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModel.swift; sourceTree = ""; }; 384A744714571BAF138C6B86 /* ClassicAppAES.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAES.swift; sourceTree = ""; }; 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreen.swift; sourceTree = ""; }; 3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenScreenModelProtocol.swift; sourceTree = ""; }; @@ -1976,6 +1974,7 @@ 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppManager.swift; sourceTree = ""; }; 3A12D3D8138F1B71AFA7C858 /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = ""; }; 3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitedRoomProxyMock.swift; sourceTree = ""; }; + 3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenCoordinator.swift; sourceTree = ""; }; 3AB34956C87731AB094DB33A /* URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTests.swift; sourceTree = ""; }; 3AD253E7EFF88F308D644272 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/SAS.strings"; sourceTree = ""; }; 3B5E97E9615A158C76B2AB77 /* DateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTests.swift; sourceTree = ""; }; @@ -2031,7 +2030,6 @@ 430C73079A84654BF46A7FF5 /* FileMediaEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMediaEventsTimelineView.swift; sourceTree = ""; }; 4338BB055A26094673CDC220 /* SpaceAddRoomsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreenViewModel.swift; sourceTree = ""; }; 434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; - 436A0D98D372B17EAE9AA999 /* GlobalSearchScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenModels.swift; sourceTree = ""; }; 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheProtocol.swift; sourceTree = ""; }; 43C2067FF58B4996323EB40C /* SessionDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDirectories.swift; sourceTree = ""; }; 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareToMapsAppActivity.swift; sourceTree = ""; }; @@ -2040,6 +2038,7 @@ 44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = ""; }; 44B71F6D9062E8EB8929BB97 /* KnockRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestCell.swift; sourceTree = ""; }; 44C314C00533E2C297796B60 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + 44C70BD39FC34A9FD2467540 /* SearchScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelProtocol.swift; sourceTree = ""; }; 44DDC82DB6A84E700CD5DEC0 /* RoomRolesAndPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsTests.swift; sourceTree = ""; }; 44ECC9D66400727DFFEE12E8 /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = ""; }; 450E04B2A976CC4C8CC1807C /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = ""; }; @@ -2127,6 +2126,7 @@ 536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = ""; }; 53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = ""; }; + 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelTests.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; 544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreen.swift; sourceTree = ""; }; 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreen.swift; sourceTree = ""; }; @@ -2296,7 +2296,6 @@ 74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppMXAccount.swift; sourceTree = ""; }; 7447C0AD7EF302CD027D6230 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SAS.strings; sourceTree = ""; }; 7463464054DDF194C54F0B04 /* LogViewerScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenViewModelProtocol.swift; sourceTree = ""; }; - 74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenCoordinator.swift; sourceTree = ""; }; 7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineItemContent.swift; sourceTree = ""; }; 748AE77AC3B0A01223033B87 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 74AE4F02A507882743973214 /* ManageAuthorizedSpacesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAuthorizedSpacesScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2583,6 +2582,7 @@ A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; A768CA51A59B8A5D8C8FD599 /* AuthenticationStartScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreen.swift; sourceTree = ""; }; A7978C9EFBDD7DE39BD86726 /* RestorationTokenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationTokenTests.swift; sourceTree = ""; }; + A79A78330B9329E3A99E4105 /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; }; A7A1B80FE6E3BA72F9C748AD /* AdvancedSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenViewModel.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A7D452AF7B5F7E3A0A7DB54C /* SessionVerificationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2693,7 +2693,6 @@ B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsCustomSectionView.swift; sourceTree = ""; }; B7728AA8046D460145EAC740 /* RoomTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTests.swift; sourceTree = ""; }; B7884BD256C091EB511B2EDF /* AppLockSetupPINScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModelProtocol.swift; sourceTree = ""; }; - B788615712FED326F73D3F83 /* GlobalSearchScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModelProtocol.swift; sourceTree = ""; }; B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsSignalling.swift; sourceTree = ""; }; B80D1901BA0B095E27793EDE /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = ""; }; @@ -2965,7 +2964,6 @@ E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = ""; }; E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = ""; }; E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = ""; }; - EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModelTests.swift; sourceTree = ""; }; EA551A98778CEE7366838CE2 /* QRCodeLoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenCoordinator.swift; sourceTree = ""; }; EA880E78AF4BD24E45A7808C /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/InfoPlist.strings; sourceTree = ""; }; EACD4855BDAD0799FD86B7B5 /* SpaceRoomListProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomListProxyProtocol.swift; sourceTree = ""; }; @@ -4019,15 +4017,6 @@ path = LegalInformationScreen; sourceTree = ""; }; - 390C92506AD893EC593D405D /* View */ = { - isa = PBXGroup; - children = ( - 24B8177BD2AF45A286F5DA31 /* GlobalSearchScreen.swift */, - 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */, - ); - path = View; - sourceTree = ""; - }; 39557ADF21345E18F3865B9E /* Emojis */ = { isa = PBXGroup; children = ( @@ -4739,6 +4728,14 @@ path = View; sourceTree = ""; }; + 62E6DBFA3C4FCB80E2B490CD /* View */ = { + isa = PBXGroup; + children = ( + A79A78330B9329E3A99E4105 /* SearchScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 638665D7260F974BA9A3BFA1 /* TestUtilities */ = { isa = PBXGroup; children = ( @@ -4826,6 +4823,18 @@ name = Products; sourceTree = ""; }; + 68E0BC14DDA20EB062EBDE95 /* SearchScreen */ = { + isa = PBXGroup; + children = ( + 3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */, + 0CD63A653BCA2C8112654B19 /* SearchScreenModels.swift */, + 1C4724D5CB16CFA5853EF8F9 /* SearchScreenViewModel.swift */, + 44C70BD39FC34A9FD2467540 /* SearchScreenViewModelProtocol.swift */, + 62E6DBFA3C4FCB80E2B490CD /* View */, + ); + path = SearchScreen; + sourceTree = ""; + }; 6B0910BCE4F1B02F124E1A09 /* TimelineItemContent */ = { isa = PBXGroup; children = ( @@ -4964,7 +4973,6 @@ 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */, 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */, 1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */, - EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */, B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */, 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */, ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */, @@ -5023,6 +5031,7 @@ F46E441BA50705E6CEC89FE0 /* RoomSummaryProviderTests.swift */, 046C0D3F53B0B5EF0A1F5BEA /* RoomSummaryTests.swift */, B7728AA8046D460145EAC740 /* RoomTests.swift */, + 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */, 848F69921527D31CAACB93AF /* SecureBackupLogoutConfirmationScreenViewModelTests.swift */, 0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */, 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */, @@ -5460,18 +5469,6 @@ path = MediaPickerScreen; sourceTree = ""; }; - 8A4738BBA7C7A299BAD70372 /* GlobalSearchScreen */ = { - isa = PBXGroup; - children = ( - 74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */, - 436A0D98D372B17EAE9AA999 /* GlobalSearchScreenModels.swift */, - 38354164AF59C5006CD05878 /* GlobalSearchScreenViewModel.swift */, - B788615712FED326F73D3F83 /* GlobalSearchScreenViewModelProtocol.swift */, - 390C92506AD893EC593D405D /* View */, - ); - path = GlobalSearchScreen; - sourceTree = ""; - }; 8A9C09B6A392465E03B8D1B1 /* IntegrationTests */ = { isa = PBXGroup; children = ( @@ -6753,7 +6750,6 @@ F5A65D1D3B83593598DC278D /* EmojiPickerScreen */, 8656AFF06650360A5D0695FF /* EncryptionReset */, 448435400B561C40E514BE1C /* FilePreviewScreen */, - 8A4738BBA7C7A299BAD70372 /* GlobalSearchScreen */, B53CA9BECD3F97805E1432D0 /* HomeScreen */, F12966DF3DA87FEF21348D60 /* InviteUsersScreen */, FFD7C58CA6A7D6BBC2F584B5 /* JoinRoomScreen */, @@ -6788,6 +6784,7 @@ 679E9837ECA8D6776079D16E /* RoomScreen */, 2E42D43DB6835A58D88B2F91 /* RoomSelectionScreen */, B329D7B5D250717039B2814A /* RoomThreadListScreen */, + 68E0BC14DDA20EB062EBDE95 /* SearchScreen */, 2565414373E6F68005966B8E /* SecureBackup */, C59BA103987B953BA374509F /* SecurityAndPrivacyScreen */, 70B74A432C241E56A7ACE610 /* Settings */, @@ -7946,7 +7943,6 @@ 25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */, 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */, 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */, - EE56238683BC3ECA9BA00684 /* GlobalSearchScreenViewModelTests.swift in Sources */, 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, 00091F143129B9A4904563C4 /* IdentityConfirmationScreenViewModelTests.swift in Sources */, @@ -8014,6 +8010,7 @@ 6AB306367E56A6F6DFA0E2FF /* RoomSummaryProviderTests.swift in Sources */, 15913A5B07118C1268A840E4 /* RoomSummaryTests.swift in Sources */, 62811275F1ED9EA55638838E /* RoomTests.swift in Sources */, + D69CCEC785E6C819551B402F /* SearchScreenViewModelTests.swift in Sources */, EB87DF90CF6F8D5D12404C6E /* SecureBackupLogoutConfirmationScreenViewModelTests.swift in Sources */, CB9FB2BEF313072C705AC9B5 /* SecurityAndPrivacyScreenViewModelTests.swift in Sources */, 53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */, @@ -8431,12 +8428,6 @@ 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */, F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */, 4295E5F850897710A51AE114 /* GeoURI.swift in Sources */, - F0DACC95F24128A54CD537E4 /* GlobalSearchScreen.swift in Sources */, - 9F11E743EA01482E78A438B0 /* GlobalSearchScreenCell.swift in Sources */, - 7D261B5119E78CC8E771CA15 /* GlobalSearchScreenCoordinator.swift in Sources */, - D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */, - 55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */, - E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */, F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */, E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, @@ -8918,6 +8909,11 @@ 0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */, 0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */, 67160204A8D362BB7D4AD259 /* Search.swift in Sources */, + 2A4B7856611589DD7B1B36CA /* SearchScreen.swift in Sources */, + 592A5B88471676D6F50B9798 /* SearchScreenCoordinator.swift in Sources */, + 3A0D1A201E2CD209A04CE89B /* SearchScreenModels.swift in Sources */, + 6B8F03A85E83755D98DF0443 /* SearchScreenViewModel.swift in Sources */, + 54804A7D8298F7E8ACAFDD76 /* SearchScreenViewModelProtocol.swift in Sources */, 22B380C579C148BA0BFB5952 /* Secrets.swift in Sources */, 339BC18777912E1989F2F17D /* Section.swift in Sources */, F833D5B5BE6707F961FA88DB /* SecureBackupController.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 450402793..91af059f3 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -1,3 +1,7 @@ +"screen_home_tab_search" = "Search"; +"screen_search_empty_state_message" = "Search for rooms"; +"screen_search_empty_state_title" = "Start searching..."; +"screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term."; "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_title" = "Clear data"; "soft_logout_clear_data_notice" = "Warning: Your personal data (including encryption keys) is still stored on this device.\n\nClear it if you’re finished using this device, or want to sign in to another account."; diff --git a/ElementX/Sources/Application/Application.swift b/ElementX/Sources/Application/Application.swift index f11bbd83f..16867ed01 100644 --- a/ElementX/Sources/Application/Application.swift +++ b/ElementX/Sources/Application/Application.swift @@ -74,7 +74,7 @@ struct Application: App { CommandGroup(after: .windowArrangement) { Button("Global Search") { - appCoordinator.handleAppRoute(.globalSearch, windowType: nil) + appCoordinator.handleAppRoute(.search, windowType: nil) } .keyboardShortcut("k", modifiers: [.command]) } diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index de57cde51..c2a458801 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -54,8 +54,8 @@ enum AppRoute: Hashable { case transferOwnership(roomID: String) /// A thread within a room, only to be used to handle tap on notification for threaded events. case thread(roomID: String, threadRootEventID: String, focusEventID: String?) - /// The global search screen - case globalSearch + /// The search screen + case search /// Whether or not the route should be handled by the authentication flow. var isAuthenticationRoute: Bool { diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index 0ada38c54..b7bef22b8 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -23,17 +23,21 @@ import SwiftUI let title: String let icon: KeyPath let selectedIcon: KeyPath + /// When `true` the tab is given the `.search` role, which on iOS 26 detaches it + /// into the trailing search button in the tab bar. + let isSearch: Bool var badgeCount = 0 /// Provide the tab's split coordinator in here to have the tab bar automatically hidden /// when pushing a child into the split view's details on iPhone/compact iPad. weak var navigationSplitCoordinator: NavigationSplitCoordinator? - init(tag: Tag, title: String, icon: KeyPath, selectedIcon: KeyPath) { + init(tag: Tag, title: String, icon: KeyPath, selectedIcon: KeyPath, isSearch: Bool = false) { self.tag = tag self.title = title self.icon = icon self.selectedIcon = selectedIcon + self.isSearch = isSearch } func barVisibility(in horizontalSizeClass: UserInterfaceSizeClass?) -> Visibility { @@ -294,18 +298,18 @@ private struct NavigationTabCoordinatorView: View { var body: some View { TabView(selection: $navigationTabCoordinator.selectedTab) { ForEach(navigationTabCoordinator.tabModules) { module in - module.coordinator?.toPresentable() - .id(module.id) - .tabItem { - Label { - Text(module.details.title) - } icon: { - CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon) - } + Tab(value: module.details.tag, role: module.details.isSearch ? .search : nil) { + module.coordinator?.toPresentable() + .id(module.id) + .toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar) + } label: { + Label { + Text(module.details.title) + } icon: { + CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon) } - .tag(module.details.tag) - .badge(module.details.badgeCount) - .toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar) + } + .badge(module.details.badgeCount) } } .backportTabBarMinimizeBehaviorOnScrollDown() diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 9053a9d76..9a004f6f7 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -24,6 +24,7 @@ nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable { var enableOnlySignedDeviceIsolationMode: Bool { get } var threadsEnabled: Bool { get } + var globalSearchEnabled: Bool { get } var hideQuietNotificationAlerts: Bool { get } } @@ -79,6 +80,7 @@ final nonisolated class AppSettings: @unchecked Sendable { case enableOnlySignedDeviceIsolationMode case knockingEnabled case threadsEnabled + case globalSearchEnabled case roomThreadListEnabled case linkPreviewsEnabled case jumpToReadMarkerEnabled @@ -439,6 +441,9 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference nonisolated(unsafe) var roomThreadListEnabled: Bool + @UserPreference + nonisolated(unsafe) var globalSearchEnabled: Bool + @UserPreference nonisolated(unsafe) var focusEventOnNotificationTap: Bool @@ -496,6 +501,7 @@ final nonisolated class AppSettings: @unchecked Sendable { _enableOnlySignedDeviceIsolationMode = UserPreference(key: .enableOnlySignedDeviceIsolationMode, defaultValue: false, storage: store) _knockingEnabled = UserPreference(key: .knockingEnabled, defaultValue: false, storage: store) _threadsEnabled = UserPreference(key: .threadsEnabled, defaultValue: false, storage: store) + _globalSearchEnabled = UserPreference(key: .globalSearchEnabled, defaultValue: ProcessInfo().isiOSAppOnMac, storage: store) _roomThreadListEnabled = UserPreference(key: .roomThreadListEnabled, defaultValue: false, storage: store) _focusEventOnNotificationTap = UserPreference(key: .focusEventOnNotificationTap, defaultValue: false, storage: store) _linkPreviewsEnabled = UserPreference(key: .linkPreviewsEnabled, defaultValue: false, storage: store) diff --git a/ElementX/Sources/Application/Windowing/WindowManager.swift b/ElementX/Sources/Application/Windowing/WindowManager.swift index 90a463ded..2981212f9 100644 --- a/ElementX/Sources/Application/Windowing/WindowManager.swift +++ b/ElementX/Sources/Application/Windowing/WindowManager.swift @@ -17,7 +17,6 @@ class WindowManager: SecureWindowManagerProtocol { private(set) var mainWindow: UIWindow! private(set) var overlayWindow: UIWindow! - private(set) var globalSearchWindow: UIWindow! private(set) var alternateWindow: UIWindow! private(set) var openWindowAction: OpenWindowAction! @@ -32,7 +31,7 @@ class WindowManager: SecureWindowManagerProtocol { } var windows: [UIWindow] { - [mainWindow, overlayWindow, globalSearchWindow, alternateWindow] + [mainWindow, overlayWindow, alternateWindow] } // periphery:ignore - auto cancels when reassigned @@ -80,11 +79,6 @@ class WindowManager: SecureWindowManagerProtocol { overlayWindow.backgroundColor = .clear overlayWindow.isHidden = false - globalSearchWindow = UIWindow(windowScene: scene) - globalSearchWindow.tintColor = .compound.textActionPrimary - globalSearchWindow.backgroundColor = .clear - globalSearchWindow.isHidden = true - alternateWindow = UIWindow(windowScene: scene) alternateWindow.tintColor = .compound.textActionPrimary @@ -138,8 +132,6 @@ class WindowManager: SecureWindowManagerProtocol { // e.g. the keyboard being displayed on top of a call sheet. mainWindow.endEditing(true) - hideGlobalSearch() - // alternateWindow.isHidden = false cannot got inside the Task otherwise the timing // is poor when you lock the phone - you briefly see the main window for a few // frames after you've unlocked the phone and then the placeholder animates in. @@ -149,41 +141,9 @@ class WindowManager: SecureWindowManagerProtocol { mainWindow.isHidden = true overlayWindow.isHidden = true - globalSearchWindow.isHidden = true } } - func showGlobalSearch() { - MXLog.info("Received global search presentation request.") - - guard alternateWindow.isHidden else { - MXLog.info("The alternate window is visible, ignoring.") - return - } - - if let mainSession { - let request = UISceneSessionActivationRequest(session: mainSession) - UIApplication.shared.activateSceneSession(for: request) { error in - MXLog.error("Failed to focus window with error: \(error)") - } - } - - globalSearchWindow.isHidden = false - globalSearchWindow.makeKeyAndVisible() - } - - func hideGlobalSearch() { - MXLog.info("Received global search dismissal request.") - - guard alternateWindow.isHidden else { - MXLog.info("The alternate window is visible, ignoring.") - return - } - - globalSearchWindow.isHidden = true - mainWindow.makeKey() - } - // MARK: - OrientationManager func setOrientation(_ orientation: UIInterfaceOrientationMask) { diff --git a/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift b/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift index d96e98d96..b7247e83e 100644 --- a/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift +++ b/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift @@ -50,19 +50,12 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol { var mainWindow: UIWindow! { get } /// Presented on top of the main window, to display e.g. user indicators. var overlayWindow: UIWindow! { get } - /// A window layered on top of the main one. Used by the global search function - var globalSearchWindow: UIWindow! { get } /// A secondary window that can be presented instead of the main/overlay window combo. var alternateWindow: UIWindow! { get } /// All the windows being managed var windows: [UIWindow] { get } - /// Makes the global search window key. Used to get automatic text field focus. - func showGlobalSearch() - - func hideGlobalSearch() - // MARK: - Secondary window support var secondaryWindowsEnabled: Bool { get set } diff --git a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift index 470bc4a9a..a763244ec 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift @@ -43,9 +43,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { // periphery:ignore - retaining purpose private var startChatFlowCoordinator: StartChatFlowCoordinator? - // periphery:ignore - retaining purpose - private var globalSearchScreenCoordinator: GlobalSearchScreenCoordinator? - private var cancellables = Set() private let sidebarNavigationStackCoordinator: NavigationStackCoordinator @@ -172,11 +169,9 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.processEvent(.presentTransferOwnershipScreen(roomID: roomID)) } - case .globalSearch: - presentGlobalSearch() case .chatBackupSettings: actionsSubject.send(.showChatBackupSettings) - case .accountProvisioningLink, .oAuthCallback, .settings, .call: + case .accountProvisioningLink, .oAuthCallback, .settings, .call, .search: break // These routes cannot be handled. } } @@ -720,45 +715,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { } } - // MARK: Global search - - private func presentGlobalSearch() { - let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider - - let coordinator = GlobalSearchScreenCoordinator(parameters: .init(roomSummaryProvider: roomSummaryProvider, - mediaProvider: userSession.mediaProvider)) - - globalSearchScreenCoordinator = coordinator - - coordinator.actions - .sink { [weak self] action in - guard let self else { return } - - switch action { - case .dismiss: - dismissGlobalSearch() - case .select(let roomID): - dismissGlobalSearch() - handleAppRoute(.room(roomID: roomID, via: []), animated: true) - actionsSubject.send(.switchToChatsTab) - } - } - .store(in: &cancellables) - - let hostingController = UIHostingController(rootView: coordinator.toPresentable()) - hostingController.view.backgroundColor = .clear - flowParameters.windowManager.globalSearchWindow.rootViewController = hostingController - - flowParameters.windowManager.showGlobalSearch() - } - - private func dismissGlobalSearch() { - flowParameters.windowManager.globalSearchWindow.rootViewController = nil - flowParameters.windowManager.hideGlobalSearch() - - globalSearchScreenCoordinator = nil - } - // MARK: Room Directory Search private func presentRoomDirectorySearch() { diff --git a/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift index e6dd287d2..a783442eb 100644 --- a/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift @@ -89,7 +89,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol { .roomDetails, .roomMemberDetails, .userProfile, .thread, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, .call, .settings, .share, .transferOwnership, - .globalSearch: + .search: // These routes aren't in this flow so clear the entire stack. clearRoute(animated: animated) case .chatBackupSettings: diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index f7bba9cad..af8c4b4e1 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -208,7 +208,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias: break // These are converted to a room ID route one level above. - case .accountProvisioningLink, .oAuthCallback, .roomList, .userProfile, .call, .settings, .chatBackupSettings, .globalSearch: + case .accountProvisioningLink, .oAuthCallback, .roomList, .userProfile, .call, .settings, .chatBackupSettings, .search: break // These routes can't be handled. case .transferOwnership(let roomID): guard self.roomID == roomID else { fatalError("Navigation route doesn't belong to this room flow.") } diff --git a/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift index 7c3298875..5de13ff4a 100644 --- a/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift @@ -123,7 +123,7 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol { case .accountProvisioningLink, .oAuthCallback, .roomList, .room, .roomDetails, .event, .userProfile, .call, .settings, .chatBackupSettings, - .share, .transferOwnership, .thread, .globalSearch: + .share, .transferOwnership, .thread, .search: break } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index f6555662b..0da890f78 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -20,7 +20,7 @@ enum UserSessionFlowCoordinatorAction { } class UserSessionFlowCoordinator: FlowCoordinatorProtocol { - enum HomeTab: Hashable { case chats, spaces } + enum HomeTab: Hashable { case chats, spaces, search } private let navigationRootCoordinator: NavigationRootCoordinator private let navigationTabCoordinator: NavigationTabCoordinator @@ -38,6 +38,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let spacesTabFlowCoordinator: SpacesTabFlowCoordinator private let spacesTabDetails: NavigationTabCoordinator.TabDetails + private let searchScreenCoordinator: SearchScreenCoordinator? + private let searchTabNavigationStackCoordinator: NavigationStackCoordinator? + private let searchTabDetails: NavigationTabCoordinator.TabDetails? + // periphery:ignore - retaining purpose private var settingsFlowCoordinator: SettingsFlowCoordinator? @@ -92,16 +96,35 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { spacesTabDetails = .init(tag: HomeTab.spaces, title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid) spacesTabDetails.navigationSplitCoordinator = spacesSplitCoordinator + if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) { + let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, + mediaProvider: flowParameters.userSession.mediaProvider)) + let searchStackCoordinator = NavigationStackCoordinator() + searchStackCoordinator.setRootCoordinator(searchCoordinator) + + searchScreenCoordinator = searchCoordinator + searchTabNavigationStackCoordinator = searchStackCoordinator + searchTabDetails = .init(tag: HomeTab.search, title: UntranslatedL10n.screenHomeTabSearch, icon: \.search, selectedIcon: \.search, isSearch: true) + } else { + searchScreenCoordinator = nil + searchTabNavigationStackCoordinator = nil + searchTabDetails = nil + } + onboardingStackCoordinator = NavigationStackCoordinator() onboardingFlowCoordinator = OnboardingFlowCoordinator(isNewLogin: isNewLogin, appLockService: appLockService, navigationStackCoordinator: onboardingStackCoordinator, flowParameters: flowParameters) - navigationTabCoordinator.setTabs([ + var tabs: [NavigationTabCoordinator.Tab] = [ .init(coordinator: chatsSplitCoordinator, details: chatsTabDetails), .init(coordinator: spacesSplitCoordinator, details: spacesTabDetails) - ]) + ] + if let searchTabNavigationStackCoordinator, let searchTabDetails { + tabs.append(.init(coordinator: searchTabNavigationStackCoordinator, details: searchTabDetails)) + } + navigationTabCoordinator.setTabs(tabs) stateMachine = flowParameters.stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial) configureStateMachine() @@ -137,12 +160,17 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, .roomDetails, .roomMemberDetails, .userProfile, .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, - .share, .transferOwnership, .thread, .globalSearch: + .share, .transferOwnership, .thread: clearPresentedSheets(animated: animated) // Make sure the presented route is visible. chatsTabFlowCoordinator.handleAppRoute(appRoute, animated: animated) if navigationTabCoordinator.selectedTab != .chats { navigationTabCoordinator.selectedTab = .chats } + case .search: + // Switch to the dedicated search tab when it's available (iOS 26 + flag), otherwise ignore. + if searchTabNavigationStackCoordinator != nil { + navigationTabCoordinator.selectedTab = .search + } } } @@ -195,6 +223,16 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } private func setupObservers() { + searchScreenCoordinator?.actionsPublisher + .sink { [weak self] action in + guard let self else { return } + switch action { + case .presentRoom(let roomID): + handleAppRoute(.room(roomID: roomID, via: []), animated: true) + } + } + .store(in: &cancellables) + chatsTabFlowCoordinator.actionsPublisher .sink { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index ef372cbae..a24c4b413 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -10,6 +10,16 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal nonisolated enum UntranslatedL10n { + /// Search + internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") } + /// Search for rooms + internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") } + /// Start searching... + internal static var screenSearchEmptyStateTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_title") } + /// There are no results for “%1$@.” Try a new search term. + internal static func screenSearchNoResultsMessage(_ p1: Any) -> String { + return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1)) + } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 1501ef653..cc79c7eec 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -14961,7 +14961,6 @@ nonisolated class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol, @unche nonisolated class WindowManagerMock: WindowManagerProtocol, @unchecked Sendable { nonisolated(unsafe) var mainWindow: UIWindow! nonisolated(unsafe) var overlayWindow: UIWindow! - nonisolated(unsafe) var globalSearchWindow: UIWindow! nonisolated(unsafe) var alternateWindow: UIWindow! nonisolated(unsafe) var windows: [UIWindow] = [] var secondaryWindowsEnabled: Bool { @@ -14970,40 +14969,6 @@ nonisolated class WindowManagerMock: WindowManagerProtocol, @unchecked Sendable } nonisolated(unsafe) var underlyingSecondaryWindowsEnabled: Bool! - //MARK: - showGlobalSearch - - private let showGlobalSearchCallsCountLock = NSLock() - private nonisolated(unsafe) var showGlobalSearchUnderlyingCallsCount = 0 - var showGlobalSearchCallsCount: Int { - get { showGlobalSearchCallsCountLock.withLock { showGlobalSearchUnderlyingCallsCount } } - set { showGlobalSearchCallsCountLock.withLock { showGlobalSearchUnderlyingCallsCount = newValue } } - } - var showGlobalSearchCalled: Bool { - return showGlobalSearchCallsCount > 0 - } - nonisolated(unsafe) var showGlobalSearchClosure: (() -> Void)? - - func showGlobalSearch() { - showGlobalSearchCallsCountLock.withLock { showGlobalSearchUnderlyingCallsCount += 1 } - showGlobalSearchClosure?() - } - //MARK: - hideGlobalSearch - - private let hideGlobalSearchCallsCountLock = NSLock() - private nonisolated(unsafe) var hideGlobalSearchUnderlyingCallsCount = 0 - var hideGlobalSearchCallsCount: Int { - get { hideGlobalSearchCallsCountLock.withLock { hideGlobalSearchUnderlyingCallsCount } } - set { hideGlobalSearchCallsCountLock.withLock { hideGlobalSearchUnderlyingCallsCount = newValue } } - } - var hideGlobalSearchCalled: Bool { - return hideGlobalSearchCallsCount > 0 - } - nonisolated(unsafe) var hideGlobalSearchClosure: (() -> Void)? - - func hideGlobalSearch() { - hideGlobalSearchCallsCountLock.withLock { hideGlobalSearchUnderlyingCallsCount += 1 } - hideGlobalSearchClosure?() - } //MARK: - registerCoordinator private let registerCoordinatorFlowCoordinatorForWindowTypeCallsCountLock = NSLock() diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index e3b577bd4..28ef538f7 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -150,9 +150,9 @@ enum RoomAvatarSizeOnScreen { 30 case .timeline, .leaveSpace, .roomDirectorySearch, .completionSuggestions, .authorizedSpaces, .createRoomSelectSpace, - .spaceFilters: + .spaceFilters, .globalSearch: 32 - case .messageForwarding, .globalSearch, .roomSelection, + case .messageForwarding, .roomSelection, .spaceAddRooms: 36 case .chats, .spaces, .spaceSettings, diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index d4862ffd8..5ffd9b792 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -52,8 +52,6 @@ enum TestablePreviewsDictionary { "FormattedBodyText_Previews" : FormattedBodyText_Previews.self, "FormattingToolbar_Previews" : FormattingToolbar_Previews.self, "FullscreenDialog_Previews" : FullscreenDialog_Previews.self, - "GlobalSearchScreenListRow_Previews" : GlobalSearchScreenListRow_Previews.self, - "GlobalSearchScreen_Previews" : GlobalSearchScreen_Previews.self, "HighlightedTimelineItemModifier_Previews" : HighlightedTimelineItemModifier_Previews.self, "HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self, "HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self, @@ -160,6 +158,7 @@ enum TestablePreviewsDictionary { "RoomSelectionScreen_Previews" : RoomSelectionScreen_Previews.self, "RoomThreadListScreen_Previews" : RoomThreadListScreen_Previews.self, "SFNumberedListView_Previews" : SFNumberedListView_Previews.self, + "SearchScreen_Previews" : SearchScreen_Previews.self, "SecureBackupKeyBackupScreen_Previews" : SecureBackupKeyBackupScreen_Previews.self, "SecureBackupLogoutConfirmationScreen_Previews" : SecureBackupLogoutConfirmationScreen_Previews.self, "SecureBackupRecoveryKeyScreen_Previews" : SecureBackupRecoveryKeyScreen_Previews.self, diff --git a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenCoordinator.swift b/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenCoordinator.swift deleted file mode 100644 index 69c5cfd35..000000000 --- a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenCoordinator.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2023-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Combine -import Compound -import SwiftUI - -struct GlobalSearchScreenCoordinatorParameters { - let roomSummaryProvider: RoomSummaryProviderProtocol - let mediaProvider: MediaProviderProtocol -} - -enum GlobalSearchControllerAction { - case dismiss - case select(roomID: String) -} - -class GlobalSearchScreenCoordinator: CoordinatorProtocol { - private let viewModel: GlobalSearchScreenViewModelProtocol - - private var cancellables = Set() - - private let actionsSubject: PassthroughSubject = .init() - var actions: AnyPublisher { - actionsSubject.eraseToAnyPublisher() - } - - init(parameters: GlobalSearchScreenCoordinatorParameters) { - viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, - mediaProvider: parameters.mediaProvider) - - viewModel.actions - .sink { [weak self] action in - guard let self else { return } - - switch action { - case .dismiss: - actionsSubject.send(.dismiss) - case .select(let roomID): - actionsSubject.send(.select(roomID: roomID)) - } - } - .store(in: &cancellables) - } - - func toPresentable() -> AnyView { - AnyView(GlobalSearchScreen(context: viewModel.context)) - } - - func stop() { - viewModel.stop() - } -} diff --git a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenModels.swift b/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenModels.swift deleted file mode 100644 index 67a890ebf..000000000 --- a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenModels.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Foundation - -enum GlobalSearchScreenViewModelAction { - case dismiss - case select(roomID: String) -} - -struct GlobalSearchScreenViewState: BindableState { - var rooms = [GlobalSearchRoom]() - var bindings: GlobalSearchScreenViewStateBindings -} - -struct GlobalSearchScreenViewStateBindings { - var searchQuery: String -} - -enum GlobalSearchScreenViewAction { - case dismiss - case select(roomID: String) - case reachedTop - case reachedBottom -} - -struct GlobalSearchRoom: Identifiable, Equatable { - let id: String - let title: String - let description: String - let avatar: RoomAvatar -} diff --git a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift b/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift deleted file mode 100644 index c78269b6a..000000000 --- a/ElementX/Sources/Screens/GlobalSearchScreen/GlobalSearchScreenViewModel.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Combine -import SwiftUI - -typealias GlobalSearchScreenViewModelType = StateStoreViewModel - -class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearchScreenViewModelProtocol { - private let roomSummaryProvider: RoomSummaryProviderProtocol - - private var actionsSubject: PassthroughSubject = .init() - var actions: AnyPublisher { - actionsSubject.eraseToAnyPublisher() - } - - init(roomSummaryProvider: RoomSummaryProviderProtocol, - mediaProvider: MediaProviderProtocol) { - self.roomSummaryProvider = roomSummaryProvider - - super.init(initialViewState: GlobalSearchScreenViewState(bindings: .init(searchQuery: "")), - mediaProvider: mediaProvider) - - roomSummaryProvider.roomListPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] summaries in - self?.updateRooms(with: summaries) - } - .store(in: &cancellables) - - context.$viewState - .map(\.bindings.searchQuery) - .removeDuplicates() - .sink { [weak self] searchQuery in - if searchQuery.isEmpty { - self?.roomSummaryProvider.setFilter(.all(filters: [])) - } else { - self?.roomSummaryProvider.setFilter(.search(query: searchQuery)) - } - } - .store(in: &cancellables) - - updateRooms(with: roomSummaryProvider.roomListPublisher.value) - } - - func stop() { - // This is a shared provider so we should reset the filtering when we are done with the view - roomSummaryProvider.setFilter(.all(filters: [])) - } - - // MARK: - Public - - override func process(viewAction: GlobalSearchScreenViewAction) { - MXLog.info("View model: received view action: \(viewAction)") - - switch viewAction { - case .dismiss: - actionsSubject.send(.dismiss) - case .select(let roomID): - actionsSubject.send(.select(roomID: roomID)) - case .reachedTop: - updateVisibleRange(edge: .top) - case .reachedBottom: - updateVisibleRange(edge: .bottom) - } - } - - // MARK: - Private - - private func updateRooms(with summaries: [RoomSummary]) { - state.rooms = summaries.compactMap { summary in - GlobalSearchRoom(id: summary.id, - title: summary.name, - description: summary.roomListDescription, - avatar: summary.avatar) - } - } - - /// The actual range values don't matter as long as they contain the lower - /// or upper bounds. updateVisibleRange is a hybrid API that powers both - /// sliding sync visible range update and list paginations - /// For lists other than the home screen one we don't care about visible ranges, - /// we just need the respective bounds to be there to trigger a next page load or - /// a reset to just one page - private func updateVisibleRange(edge: UIRectEdge) { - switch edge { - case .top: - roomSummaryProvider.updateVisibleRange(0..<0) - case .bottom: - let roomCount = roomSummaryProvider.roomListPublisher.value.count - roomSummaryProvider.updateVisibleRange(roomCount.. { get } - var context: GlobalSearchScreenViewModelType.Context { get } - - func stop() -} diff --git a/ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreen.swift b/ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreen.swift deleted file mode 100644 index 24e4ac9dd..000000000 --- a/ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreen.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Compound -import SwiftUI - -struct GlobalSearchScreen: View { - @ObservedObject var context: GlobalSearchScreenViewModel.Context - - @State private var selectedRoom: GlobalSearchRoom? - @FocusState private var searchFieldFocus - - var body: some View { - List { - header - - Section { - ForEach(context.viewState.rooms) { room in - GlobalSearchScreenListRow(room: room, context: context) - .listRowBackground(backgroundColor(for: room)) - .listRowInsets(.init()) - .contentShape(.rect) - .onTapGesture { - context.send(viewAction: .select(roomID: room.id)) - } - .onAppear { - if room == context.viewState.rooms.first { - context.send(viewAction: .reachedTop) - } else if room == context.viewState.rooms.last { - context.send(viewAction: .reachedBottom) - } - } - } - } - } - .listStyle(.plain) - .frame(maxWidth: 700, maxHeight: 800) - .background(.compound.bgCanvasDefault) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black.opacity(0.5).ignoresSafeArea()) - .background { keyboardShortcuts } - .onAppear { - selectedRoom = context.viewState.rooms.first - searchFieldFocus = true - } - .onChange(of: context.viewState.rooms) { - selectedRoom = context.viewState.rooms.first - } - .onTapGesture { - context.send(viewAction: .dismiss) - } - } - - private var header: some View { - GlobalSearchTextFieldRepresentable(placeholder: L10n.actionSearch, text: $context.searchQuery) { keyCode in - switch keyCode { - case .keyboardUpArrow: - moveToNextEntry(backwards: true) - return true - case .keyboardDownArrow: - moveToNextEntry() - return true - case .keyboardReturnOrEnter, .keyboardReturn: - if let selectedRoom { - context.send(viewAction: .select(roomID: selectedRoom.id)) - } - return true - case .keyboardEscape: - context.send(viewAction: .dismiss) - return true - default: - return false - } - } endEditingHandler: { - if let selectedRoom { - context.send(viewAction: .select(roomID: selectedRoom.id)) - } else { // Bring the focus back to the text field - searchFieldFocus = true - } - } - .focused($searchFieldFocus) - .autocorrectionDisabled(true) - .autocapitalization(.none) - .textInputAutocapitalization(.never) - } - - private var keyboardShortcuts: some View { - Button("") { - context.send(viewAction: .dismiss) - } - // Need this to enable escape on the textField and forward the presses - .keyboardShortcut(.escape, modifiers: []) - } - - private func backgroundColor(for room: GlobalSearchRoom) -> Color { - if selectedRoom == room { - .compound.bgSubtlePrimary - } else { - .compound.bgCanvasDefault - } - } - - private func moveToNextEntry(backwards: Bool = false) { - guard let selectedRoom else { - selectedRoom = context.viewState.rooms.first - return - } - - guard let currentIndex = context.viewState.rooms.firstIndex(of: selectedRoom) else { - return - } - - let nextIndex = (backwards ? currentIndex - 1 : currentIndex + 1) - - guard context.viewState.rooms.indices.contains(nextIndex) else { - return - } - - self.selectedRoom = context.viewState.rooms[nextIndex] - } -} - -private struct GlobalSearchTextFieldRepresentable: UIViewRepresentable { - let placeholder: String - @Binding var text: String - let keyPressHandler: (UIKeyboardHIDUsage) -> Bool - let endEditingHandler: () -> Void - - func makeUIView(context: Context) -> UITextField { - let textField = GlobalSearchTextField(keyPressHandler: keyPressHandler) - textField.delegate = context.coordinator - textField.autocorrectionType = .no - textField.placeholder = placeholder - return textField - } - - func updateUIView(_ uiView: UITextField, context: Context) { - uiView.text = text - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text, endEditingHandler: endEditingHandler) - } - - class Coordinator: NSObject, UITextFieldDelegate { - var text: Binding - let endEditingHandler: () -> Void - - init(text: Binding, endEditingHandler: @escaping () -> Void) { - self.text = text - self.endEditingHandler = endEditingHandler - } - - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - // pressesBegan sometimes doesn't receive return events. Handle it here instead - if string.rangeOfCharacter(from: .newlines) != nil { - endEditingHandler() - return false - } - - let currentText = textField.text ?? "" - DispatchQueue.main.async { - self.text.wrappedValue = (currentText as NSString).replacingCharacters(in: range, with: string) - } - return true - } - } -} - -private class GlobalSearchTextField: UITextField { - let keyPressHandler: (UIKeyboardHIDUsage) -> Bool - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError() - } - - init(keyPressHandler: @escaping (UIKeyboardHIDUsage) -> Bool) { - self.keyPressHandler = keyPressHandler - super.init(frame: .zero) - } - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let key = presses.first?.key else { - super.pressesBegan(presses, with: event) - return - } - - if keyPressHandler(key.keyCode) { - return - } - - super.pressesBegan(presses, with: event) - } -} - -// MARK: - Previews - -struct GlobalSearchScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - mediaProvider: MediaProviderMock(.init())) - - static var previews: some View { - ElementNavigationStack { - GlobalSearchScreen(context: viewModel.context) - } - } -} diff --git a/ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreenCell.swift b/ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreenCell.swift deleted file mode 100644 index f69e2bc2c..000000000 --- a/ElementX/Sources/Screens/GlobalSearchScreen/View/GlobalSearchScreenCell.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2024-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Compound -import SwiftUI - -struct GlobalSearchScreenListRow: View { - @Environment(\.dynamicTypeSize) var dynamicTypeSize - - let room: GlobalSearchRoom - let context: GlobalSearchScreenViewModel.Context - - var body: some View { - ZStack { // The list row swallows listRowBackgrounds for some reason - ListRow(label: .avatar(title: room.title, - description: room.description, - icon: avatar), - kind: .label) - } - } - - @ViewBuilder - var avatar: some View { - if dynamicTypeSize < .accessibility3 { - RoomAvatarImage(avatar: room.avatar, - avatarSize: .room(on: .messageForwarding), - mediaProvider: context.mediaProvider) - .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) - .accessibilityHidden(true) - } - } -} - -struct GlobalSearchScreenListRow_Previews: PreviewProvider, TestablePreview { - static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - mediaProvider: MediaProviderMock(.init())) - - static var previews: some View { - List { - GlobalSearchScreenListRow(room: .init(id: "123", - title: "Tech central", - description: "The best place in the whole wide world", - avatar: .room(id: "123", - name: "Tech central", - avatarURL: .mockMXCAvatar)), - context: viewModel.context) - } - } -} diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 24936d38b..b741f3491 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -115,6 +115,9 @@ struct HomeScreenViewState: BindableState { var shouldShowSpaceFilters = false var selectedSpaceFilter: SpaceServiceFilter? + /// Inline room list search is disabled when the dedicated global search tab is shown instead (see `UserSessionFlowCoordinator`). + var isRoomListSearchEnabled = true + var visibleRooms: [HomeScreenRoom] { if roomListMode == .skeletons { return placeholderRooms diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index beccd67f9..4760859c3 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -49,6 +49,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol bindings: .init(filtersState: .init(appSettings: appSettings))), mediaProvider: userSession.mediaProvider) + if appSettings.globalSearchEnabled, #available(iOS 26.0, *) { + state.isRoomListSearchEnabled = false + } + userSession.clientProxy.userAvatarURLPublisher .receive(on: DispatchQueue.main) .weakAssign(to: \.state.userAvatarURL, on: self) diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift index 1715dd028..50716ff96 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift @@ -59,10 +59,9 @@ struct HomeScreenContent: View { topSection } } - .isSearching($context.isSearchFieldFocused) - .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always)) - .compoundSearchField() - .disableAutocorrection(true) + .roomListSearchable(isEnabled: context.viewState.isRoomListSearchEnabled, + isSearchFieldFocused: $context.isSearchFieldFocused, + searchQuery: $context.searchQuery) } } .introspect(.scrollView, on: .supportedVersions) { scrollView in @@ -161,3 +160,17 @@ struct HomeScreenContent: View { context.send(viewAction: .updateVisibleItemRange(firstIndex.., searchQuery: Binding) -> some View { + if isEnabled { + isSearching(isSearchFieldFocused) + .searchable(text: searchQuery, placement: .navigationBarDrawer(displayMode: .always)) + .compoundSearchField() + .disableAutocorrection(true) + } else { + self + } + } +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift new file mode 100644 index 000000000..e663ed8d1 --- /dev/null +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -0,0 +1,49 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +struct SearchScreenCoordinatorParameters { + let roomSummaryProvider: RoomSummaryProviderProtocol + let mediaProvider: MediaProviderProtocol +} + +enum SearchScreenCoordinatorAction { + case presentRoom(roomID: String) +} + +final class SearchScreenCoordinator: CoordinatorProtocol { + private let viewModel: SearchScreenViewModelProtocol + + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: SearchScreenCoordinatorParameters) { + viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, + mediaProvider: parameters.mediaProvider) + } + + func start() { + viewModel.actionsPublisher.sink { [weak self] action in + guard let self else { return } + switch action { + case .presentRoom(let roomID): + actionsSubject.send(.presentRoom(roomID: roomID)) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(SearchScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift new file mode 100644 index 000000000..d8f423eb6 --- /dev/null +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -0,0 +1,39 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum SearchScreenViewModelAction { + case presentRoom(roomID: String) +} + +struct SearchScreenViewState: BindableState { + var rooms = [SearchScreenRoom]() + var bindings: SearchScreenViewStateBindings + + var isSearching: Bool { + !bindings.searchQuery.isEmpty + } +} + +struct SearchScreenViewStateBindings { + var searchQuery = "" +} + +enum SearchScreenViewAction { + case appeared + case selectRoom(roomID: String) + case reachedTop + case reachedBottom +} + +struct SearchScreenRoom: Identifiable, Equatable { + let id: String + let title: String + let description: String + let avatar: RoomAvatar +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift new file mode 100644 index 000000000..05a7baf2d --- /dev/null +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -0,0 +1,115 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import AsyncAlgorithms +import Combine +import SwiftUI + +typealias SearchScreenViewModelType = StateStoreViewModelV2 + +class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol { + private let roomSummaryProvider: RoomSummaryProviderProtocol + private var searchQueryObservationTask: Task? + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(roomSummaryProvider: RoomSummaryProviderProtocol, + mediaProvider: MediaProviderProtocol, + initialSearchQuery: String = "") { + self.roomSummaryProvider = roomSummaryProvider + + super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery)), + mediaProvider: mediaProvider) + + roomSummaryProvider.roomListPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] summaries in + self?.updateRooms(with: summaries) + } + .store(in: &cancellables) + + searchQueryObservationTask = Task { [weak self] in + guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } + for await searchQuery in stream { + self?.updateFilter(for: searchQuery) + } + } + + updateRooms(with: roomSummaryProvider.roomListPublisher.value) + } + + isolated deinit { + searchQueryObservationTask?.cancel() + } + + // MARK: - Public + + override func process(viewAction: SearchScreenViewAction) { + MXLog.info("View model: received view action: \(viewAction)") + + switch viewAction { + case .appeared: + // The provider is shared, so other consumers may have changed its filter while we were off-screen. + // Re-apply ours on every appearance to keep the displayed results in sync with the query. + updateFilter(for: state.bindings.searchQuery) + case .selectRoom(let roomID): + actionsSubject.send(.presentRoom(roomID: roomID)) + case .reachedTop: + updateVisibleRange(edge: .top) + case .reachedBottom: + updateVisibleRange(edge: .bottom) + } + } + + // MARK: - Private + + private func updateFilter(for searchQuery: String) { + if searchQuery.isEmpty { + roomSummaryProvider.setFilter(.excludeAll) + } else { + roomSummaryProvider.setFilter(.search(query: searchQuery)) + } + } + + private func updateRooms(with summaries: [RoomSummary]) { + state.rooms = summaries.map { summary in + // Show the matrix identifier as the subtitle: the other member's user ID for DMs, + // otherwise the room's canonical alias. + let identifier = if summary.isDirect { + summary.heroes.first?.userID ?? summary.canonicalAlias + } else { + summary.canonicalAlias + } + + return SearchScreenRoom(id: summary.id, + title: summary.name, + description: identifier ?? "", + avatar: summary.avatar) + } + } + + /// The actual range values don't matter as long as they contain the lower + /// or upper bounds. updateVisibleRange is a hybrid API that powers both + /// sliding sync visible range update and list paginations. + /// For lists other than the home screen one we don't care about visible ranges, + /// we just need the respective bounds to be there to trigger a next page load or + /// a reset to just one page. + private func updateVisibleRange(edge: UIRectEdge) { + switch edge { + case .top: + roomSummaryProvider.updateVisibleRange(0..<0) + case .bottom: + let roomCount = roomSummaryProvider.roomListPublisher.value.count + roomSummaryProvider.updateVisibleRange(roomCount.. { get } + var context: SearchScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift new file mode 100644 index 000000000..8273ba77b --- /dev/null +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -0,0 +1,263 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import GameController +import SwiftUI +import SwiftUIIntrospect + +struct SearchScreen: View { + @Bindable var context: SearchScreenViewModel.Context + + @FocusState private var isSearchFieldFocused: Bool + /// The room highlighted for hardware keyboard selection (arrow keys + return). + @State private var selectedRoomID: String? + /// The selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise. + @State private var isHardwareKeyboardConnected = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Rendered as content rather than a navigation title so it stays visible while the search field is focused. + Text(L10n.actionSearch) + .font(.compound.headingXLBold) + .foregroundStyle(.compound.textPrimary) + .accessibilityAddTraits(.isHeader) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + + if context.viewState.rooms.isEmpty { + emptyState + } else { + roomList + } + } + .frame(maxHeight: .infinity, alignment: .top) + .background(Color.compound.bgCanvasDefault) + .searchable(text: $context.searchQuery, placement: .toolbarPrincipal) + .searchFocused($isSearchFieldFocused) + .autocorrectionDisabled(true) + .onSubmit(of: .search) { + if let selectedRoomID { + context.send(viewAction: .selectRoom(roomID: selectedRoomID)) + } + } + .searchResultsKeyboardNavigation(moveUp: { moveSelection(backwards: true) }, + moveDown: { moveSelection(backwards: false) }) + // The TabView calls onAppear each time the search tab is selected, so the field re-focuses + // and the selection resets on every switch. + .onAppear { + context.send(viewAction: .appeared) + isSearchFieldFocused = true + selectedRoomID = context.viewState.rooms.first?.id + updateHardwareKeyboardConnected() + } + .onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in + updateHardwareKeyboardConnected() + } + .onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in + updateHardwareKeyboardConnected() + } + // Reset to the first result when the results change (e.g. a new query), keyed on the top result + // so pagination and background updates that leave it untouched don't clobber the selection. + .onChange(of: context.viewState.rooms.first?.id) { + selectedRoomID = context.viewState.rooms.first?.id + } + } + + @ViewBuilder + private var emptyState: some View { + if context.viewState.isSearching { + TitleAndIcon(title: L10n.commonNoResults, + subtitle: UntranslatedL10n.screenSearchNoResultsMessage(context.searchQuery), + icon: \.search, + iconStyle: .defaultSolid) + .frame(maxWidth: .infinity) + .padding(40) + } else { + TitleAndIcon(title: UntranslatedL10n.screenSearchEmptyStateTitle, + subtitle: UntranslatedL10n.screenSearchEmptyStateMessage, + icon: \.search, + iconStyle: .defaultSolid) + .frame(maxWidth: .infinity) + .padding(40) + } + } + + private var roomList: some View { + List { + ForEach(context.viewState.rooms) { room in + SearchScreenRoomCell(room: room, + context: context, + isLast: room == context.viewState.rooms.last, + isSelected: isHardwareKeyboardConnected && selectedRoomID == room.id) + .onAppear { + if room == context.viewState.rooms.first { + context.send(viewAction: .reachedTop) + } else if room == context.viewState.rooms.last { + context.send(viewAction: .reachedBottom) + } + } + } + } + .compoundList(.plain) + } + + private func moveSelection(backwards: Bool) { + let rooms = context.viewState.rooms + guard let selectedRoomID, + let currentIndex = rooms.firstIndex(where: { $0.id == selectedRoomID }) + else { + selectedRoomID = rooms.first?.id + return + } + + let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1 + guard rooms.indices.contains(nextIndex) else { return } + self.selectedRoomID = rooms[nextIndex].id + } + + private func updateHardwareKeyboardConnected() { + isHardwareKeyboardConnected = GCKeyboard.coalesced != nil + } +} + +private struct SearchScreenRoomCell: View { + let room: SearchScreenRoom + let context: SearchScreenViewModel.Context + let isLast: Bool + let isSelected: Bool + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + Button { + context.send(viewAction: .selectRoom(roomID: room.id)) + } label: { + HStack(spacing: 12) { + avatar + + VStack(alignment: .leading, spacing: 0) { + Text(room.title) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + .lineLimit(1) + + if !room.description.isEmpty { + Text(room.description) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected)) + .listRowInsets(.init()) + .listRowSeparator(.hidden) + .rowDivider() + } + + @ViewBuilder + private var avatar: some View { + if dynamicTypeSize < .accessibility3 { + RoomAvatarImage(avatar: room.avatar, + avatarSize: .room(on: .globalSearch), + mediaProvider: context.mediaProvider) + .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) + .accessibilityHidden(true) + } + } +} + +private struct SearchScreenRoomCellButtonStyle: ButtonStyle { + let isSelected: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(isSelected || configuration.isPressed ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault) + .contentShape(Rectangle()) + .animation(isSelected ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isSelected) + } +} + +private extension View { + /// Forwards hardware up/down arrow presses from the system search field to the given closures. + /// + /// The `.search` role tab owns its text field, so there's no SwiftUI hook for its key presses. We reach the + /// field through the navigation stack (as ``SearchFieldStyle`` does) and swap its class for one that overrides + /// `pressesBegan` — the same key interception the old global search performed on its own field. + func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void) -> some View { + introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in + guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else { + return + } + + if !(textField is KeyNavigatingSearchTextField) { + object_setClass(textField, KeyNavigatingSearchTextField.self) + } + + KeyNavigatingSearchTextField.keyHandler = { keyCode in + switch keyCode { + case .keyboardUpArrow: + moveUp() + case .keyboardDownArrow: + moveDown() + default: + return false + } + return true + } + } + } +} + +/// A `UISearchTextField` whose class is installed at runtime via `object_setClass`, so it must not declare any +/// stored properties. The handler is static, which is fine as there's only ever a single search field. +private final class KeyNavigatingSearchTextField: UISearchTextField { + static var keyHandler: ((UIKeyboardHIDUsage) -> Bool)? + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + if let keyCode = presses.first?.key?.keyCode, Self.keyHandler?(keyCode) == true { + return + } + super.pressesBegan(presses, with: event) + } +} + +// MARK: - Previews + +struct SearchScreen_Previews: PreviewProvider, TestablePreview { + static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + mediaProvider: MediaProviderMock(.init())) + static let loadedViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation") + static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "John Doe") + + static var previews: some View { + ElementNavigationStack { + SearchScreen(context: emptyViewModel.context) + } + .previewDisplayName("Empty") + + ElementNavigationStack { + SearchScreen(context: noResultsViewModel.context) + } + .previewDisplayName("No results") + + ElementNavigationStack { + SearchScreen(context: loadedViewModel.context) + } + .previewDisplayName("Loaded") + } +} diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 31d4535dc..3a47c7fa9 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -74,6 +74,8 @@ protocol DeveloperOptionsProtocol: AnyObject { var linkNewDeviceEnabled: Bool { get set } var roomThreadListEnabled: Bool { get set } + + var globalSearchEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index c9a55adc2..a46932155 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -49,6 +49,11 @@ struct DeveloperOptionsScreen: View { Text("Link new device with QR code") } + Toggle(isOn: $context.globalSearchEnabled) { + Text("Global search") + Text("Moves search to a separate tab") + } + context.viewState.appHooks .developerOptionsScreenHook .generalSectionRows() diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift index f0b679814..ebf7a1e8b 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift @@ -121,7 +121,7 @@ extension RoomSummary: CustomStringConvertible { extension RoomSummary { /// An empty summary used when the room info is momentarily unavailable (e.g. while the client tears /// down on logout). Keeps the diff indices aligned with the SDK instead of crashing. - static func placeholder(room: Room) -> RoomSummary { + nonisolated static func placeholder(room: Room) -> RoomSummary { RoomSummary(room: room, id: room.id(), joinRequestType: nil, diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 3a298a916..9430626f3 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -312,20 +312,6 @@ extension PreviewTests { } } - @Test - func globalSearchScreenListRow() async throws { - for (index, preview) in GlobalSearchScreenListRow_Previews._allPreviews.enumerated() { - try await assertSnapshots(matching: preview, step: index) - } - } - - @Test - func globalSearchScreen() async throws { - for (index, preview) in GlobalSearchScreen_Previews._allPreviews.enumerated() { - try await assertSnapshots(matching: preview, step: index) - } - } - @Test func highlightedTimelineItemModifier() async throws { for (index, preview) in HighlightedTimelineItemModifier_Previews._allPreviews.enumerated() { @@ -1068,6 +1054,13 @@ extension PreviewTests { } } + @Test + func searchScreen() async throws { + for (index, preview) in SearchScreen_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func secureBackupKeyBackupScreen() async throws { for (index, preview) in SecureBackupKeyBackupScreen_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-en-GB-0.png deleted file mode 100644 index 061669913..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2e96b757eacb7be702a40a8f9494f8900e2ada38838c78a38190a3837d56a2ec -size 171848 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-pseudo-0.png deleted file mode 100644 index a27319c3f..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPad-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:456fe50194ca5e15d431437a6342bc0664266cdc54d8084dd18639980cf0eb60 -size 172156 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-en-GB-0.png deleted file mode 100644 index 9b8006abe..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:611812894a3f67b80d5f32a9192eb787262c4c05fb869a79ceb561ac88865047 -size 106611 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-pseudo-0.png deleted file mode 100644 index a670d3f9b..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreen.iPhone-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:80c1dffec6a31d5383f694ad063dbb1f6614f4c8da21b94e8d4b587fa355b706 -size 106942 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-en-GB-0.png deleted file mode 100644 index 077de0b19..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5b9d73c339d27730080a8620d3c3dfee9e8bb05f22a911d30a57bcab71fb18a1 -size 98755 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-pseudo-0.png deleted file mode 100644 index 077de0b19..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPad-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5b9d73c339d27730080a8620d3c3dfee9e8bb05f22a911d30a57bcab71fb18a1 -size 98755 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-en-GB-0.png deleted file mode 100644 index cf52f1455..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-en-GB-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b47056a891a64a3c4a5ead1161a98ca8e16319727cb8962f95f8a8ddc47e2a45 -size 50155 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-pseudo-0.png deleted file mode 100644 index cf52f1455..000000000 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/globalSearchScreenListRow.iPhone-pseudo-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b47056a891a64a3c4a5ead1161a98ca8e16319727cb8962f95f8a8ddc47e2a45 -size 50155 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png new file mode 100644 index 000000000..fd8c9a10a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a93a6802d8a053abd390e9311921294dfbab18844eb17a63eaf4b8c623173a9 +size 85119 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png new file mode 100644 index 000000000..cd515fcab --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72835c30b6f459759cbbae44aaba4aed0c39674c49879602c7a8a319323b9db1 +size 88205 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png new file mode 100644 index 000000000..c92f88904 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25f1c159081bfcf7e1fb181bb6f1ee2f614a1ae3759052cc5dac3f7266b271ba +size 42662 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png new file mode 100644 index 000000000..d1ff6de07 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Empty-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:513a8d3815582253be1eecf22a7726c7917c9e83e7465aa5e7b90ef2d7944c1e +size 49713 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-en-GB.png new file mode 100644 index 000000000..5406ebdb9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69ddd3c435d8c50a231bdc128c059127219d35624a16912579ce0f878830a274 +size 148724 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-pseudo.png new file mode 100644 index 000000000..c76f439d9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:680e82d9b7877d868e734783ac7d9dc9c615d7c53a80b816e7529cbda6e3be0d +size 149586 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-en-GB.png new file mode 100644 index 000000000..dead27c49 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a419004009267be00c3bb862408e82639e2fb45f6951e77da9309b1245f046d +size 103648 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-pseudo.png new file mode 100644 index 000000000..d1bb683f8 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Loaded-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a6a0b062ae788f675ada0f9011ff70dea366bf36c6b5d5a13a5bd909a03bc6c +size 104605 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png new file mode 100644 index 000000000..dba50b36d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d6cd51861f45a9e393eb3194ee7224a094646ce4385f2aa8ecb1be404bbaa6d +size 87898 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png new file mode 100644 index 000000000..808e93546 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c96db7daacd17e38fb6d0d74cc9de51f52b0c17fb2c02c2c162d8384ca1ab6d2 +size 94243 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png new file mode 100644 index 000000000..c789b0415 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31369bff674e454cb3cacdcb7a33efe035c838e4d09b549ba07979597d74a744 +size 49368 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png new file mode 100644 index 000000000..977678ec9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.No-results-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cc94abaa65358e0cf82a0635b0aaa5f7858f0a765b3ddf0f8b647cba493cfe6 +size 58249 diff --git a/UnitTests/Sources/GlobalSearchScreenViewModelTests.swift b/UnitTests/Sources/GlobalSearchScreenViewModelTests.swift deleted file mode 100644 index 55bcbb9ed..000000000 --- a/UnitTests/Sources/GlobalSearchScreenViewModelTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Copyright 2025 Element Creations Ltd. -// Copyright 2022-2025 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Combine -@testable import ElementX -import Testing - -@MainActor -struct GlobalSearchScreenViewModelTests { - var viewModel: GlobalSearchScreenViewModelProtocol! - var context: GlobalSearchScreenViewModelType.Context! - - init() { - viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - mediaProvider: MediaProviderMock(.init())) - context = viewModel.context - } - - @Test - mutating func searching() async throws { - let deferred = deferFulfillment(context.$viewState) { state in - state.rooms.count == 1 - } - - context.searchQuery = "Second" - - try await deferred.fulfill() - } - - @Test - func roomSelection() async throws { - let deferred = deferFulfillment(viewModel.actions) { action in - switch action { - case .select(let roomID): - return roomID == "2" - default: - return false - } - } - - context.send(viewAction: .select(roomID: "2")) - - try await deferred.fulfill() - } -} diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift new file mode 100644 index 000000000..52c7e76f1 --- /dev/null +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -0,0 +1,43 @@ +// +// 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. +// + +@testable import ElementX +import Testing + +@MainActor +struct SearchScreenViewModelTests { + let viewModel: SearchScreenViewModelProtocol + var context: SearchScreenViewModelType.Context { + viewModel.context + } + + init() { + viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + mediaProvider: MediaProviderMock(.init())) + } + + @Test + func searching() async throws { + let deferred = deferFulfillment(context.observe(\.viewState.rooms)) { $0.count == 1 } + context.searchQuery = "Second" + try await deferred.fulfill() + } + + @Test + func roomSelection() async throws { + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + switch action { + case .presentRoom(let roomID): + roomID == "2" + } + } + + context.send(viewAction: .selectRoom(roomID: "2")) + + try await deferred.fulfill() + } +}