Search tab (#5754)

* Add a developer flag for the search tab
Introduces a globalSearchEnabled user preference (off by default) with a toggle in Developer Options, used to gate the new search experience.

* Support search role tabs in the tab coordinator
Migrates the NavigationTabCoordinator to SwiftUI's value-based Tab API and adds an isSearch option so a tab can adopt the iOS 26 .search role, which detaches it into the trailing search button in the tab bar.

* Moves room search to a new screen
Adds a new screen that filters the alternate room summary provider as you type, shows an empty state, and lists matching rooms with their matrix identifier. Supports hardware keyboard selection (arrow keys to move through results, return to open) by reaching into the system search field.

* Present search in a dedicated tab
Adds the search tab to the chats flow on iOS 26 when the flag is enabled, rendered as the detached .search role button. The Cmd+K shortcut now selects this tab, and the home screen's inline search is hidden while the tab is available.

* Remove the old global search screen
The dedicated search tab replaces the Cmd+K floating window, so the GlobalSearchScreen, its window plumbing in the WindowManager, and its presentation in the chats flow are all removed.
This commit is contained in:
Stefan Ceriu
2026-06-19 13:50:17 +03:00
committed by GitHub
parent 9a6f7595e5
commit 7f1b6086ae
58 changed files with 731 additions and 780 deletions
@@ -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")
}
+40 -44
View File
@@ -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 = "<group>"; };
0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsProtocol.swift; sourceTree = "<group>"; };
0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
0CD63A653BCA2C8112654B19 /* SearchScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenModels.swift; sourceTree = "<group>"; };
0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenModels.swift; sourceTree = "<group>"; };
0D432771B389EC486E7F90C6 /* CreateRoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenModels.swift; sourceTree = "<group>"; };
0D879FC4E881E748BB9B34DC /* RoomChangePermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenCoordinator.swift; sourceTree = "<group>"; };
@@ -1808,6 +1808,7 @@
1C21A715237F2B6D6E80998C /* SecureBackupControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupControllerProtocol.swift; sourceTree = "<group>"; };
1C25B6EBEB414431187D73B7 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = "<group>"; };
1C3A7F76FE12E274D2ED8DF4 /* MapTilerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerSettings.swift; sourceTree = "<group>"; };
1C4724D5CB16CFA5853EF8F9 /* SearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModel.swift; sourceTree = "<group>"; };
1C78111573987B1D79ED0868 /* LinkMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkMetadataProvider.swift; sourceTree = "<group>"; };
1C782FCBBCC9A0CD30453C50 /* ChatsTabFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsTabFlowCoordinator.swift; sourceTree = "<group>"; };
1C7F63EB1525E697CAEB002B /* BlankFormCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlankFormCoordinator.swift; sourceTree = "<group>"; };
@@ -1850,7 +1851,6 @@
22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModelProtocol.swift; sourceTree = "<group>"; };
227AC5D71A4CE43512062243 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
22B384D54464FA39C6C7F6E7 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenCell.swift; sourceTree = "<group>"; };
233D5F7E5E9F49ABF3413291 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hr; path = hr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
2355398E4A55DA5A89128AD1 /* EncryptionKeyProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyProvider.swift; sourceTree = "<group>"; };
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 = "<group>"; };
2441E2424E78A40FC95DBA76 /* AudioRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderTests.swift; sourceTree = "<group>"; };
248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyMock.swift; sourceTree = "<group>"; };
24B8177BD2AF45A286F5DA31 /* GlobalSearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreen.swift; sourceTree = "<group>"; };
24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModel.swift; sourceTree = "<group>"; };
24E637CF570711FB5FD63DEA /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = "<group>"; };
24EC819497BB5F8C4998D760 /* RoomListFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterView.swift; sourceTree = "<group>"; };
@@ -1967,7 +1966,6 @@
37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreen.swift; sourceTree = "<group>"; };
382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyMock.swift; sourceTree = "<group>"; };
38345442415E07A931197C55 /* AppLockScreenPINKeypad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenPINKeypad.swift; sourceTree = "<group>"; };
38354164AF59C5006CD05878 /* GlobalSearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModel.swift; sourceTree = "<group>"; };
384A744714571BAF138C6B86 /* ClassicAppAES.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAES.swift; sourceTree = "<group>"; };
38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreen.swift; sourceTree = "<group>"; };
3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenScreenModelProtocol.swift; sourceTree = "<group>"; };
@@ -1976,6 +1974,7 @@
39C78D5FF15343724902EB20 /* ClassicAppManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppManager.swift; sourceTree = "<group>"; };
3A12D3D8138F1B71AFA7C858 /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = "<group>"; };
3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitedRoomProxyMock.swift; sourceTree = "<group>"; };
3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenCoordinator.swift; sourceTree = "<group>"; };
3AB34956C87731AB094DB33A /* URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTests.swift; sourceTree = "<group>"; };
3AD253E7EFF88F308D644272 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/SAS.strings"; sourceTree = "<group>"; };
3B5E97E9615A158C76B2AB77 /* DateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTests.swift; sourceTree = "<group>"; };
@@ -2031,7 +2030,6 @@
430C73079A84654BF46A7FF5 /* FileMediaEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMediaEventsTimelineView.swift; sourceTree = "<group>"; };
4338BB055A26094673CDC220 /* SpaceAddRoomsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreenViewModel.swift; sourceTree = "<group>"; };
434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = "<group>"; };
436A0D98D372B17EAE9AA999 /* GlobalSearchScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenModels.swift; sourceTree = "<group>"; };
43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheProtocol.swift; sourceTree = "<group>"; };
43C2067FF58B4996323EB40C /* SessionDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDirectories.swift; sourceTree = "<group>"; };
4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareToMapsAppActivity.swift; sourceTree = "<group>"; };
@@ -2040,6 +2038,7 @@
44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = "<group>"; };
44B71F6D9062E8EB8929BB97 /* KnockRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestCell.swift; sourceTree = "<group>"; };
44C314C00533E2C297796B60 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = "<group>"; };
44C70BD39FC34A9FD2467540 /* SearchScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelProtocol.swift; sourceTree = "<group>"; };
44DDC82DB6A84E700CD5DEC0 /* RoomRolesAndPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsTests.swift; sourceTree = "<group>"; };
44ECC9D66400727DFFEE12E8 /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = "<group>"; };
450E04B2A976CC4C8CC1807C /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = "<group>"; };
@@ -2127,6 +2126,7 @@
536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = "<group>"; };
53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = "<group>"; };
5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelTests.swift; sourceTree = "<group>"; };
5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListScreen.swift; sourceTree = "<group>"; };
5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreen.swift; sourceTree = "<group>"; };
@@ -2296,7 +2296,6 @@
74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppMXAccount.swift; sourceTree = "<group>"; };
7447C0AD7EF302CD027D6230 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SAS.strings; sourceTree = "<group>"; };
7463464054DDF194C54F0B04 /* LogViewerScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenViewModelProtocol.swift; sourceTree = "<group>"; };
74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenCoordinator.swift; sourceTree = "<group>"; };
7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineItemContent.swift; sourceTree = "<group>"; };
748AE77AC3B0A01223033B87 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
74AE4F02A507882743973214 /* ManageAuthorizedSpacesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAuthorizedSpacesScreenViewModelProtocol.swift; sourceTree = "<group>"; };
@@ -2583,6 +2582,7 @@
A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; };
A768CA51A59B8A5D8C8FD599 /* AuthenticationStartScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreen.swift; sourceTree = "<group>"; };
A7978C9EFBDD7DE39BD86726 /* RestorationTokenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationTokenTests.swift; sourceTree = "<group>"; };
A79A78330B9329E3A99E4105 /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
A7A1B80FE6E3BA72F9C748AD /* AdvancedSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenViewModel.swift; sourceTree = "<group>"; };
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
A7D452AF7B5F7E3A0A7DB54C /* SessionVerificationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
@@ -2693,7 +2693,6 @@
B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsCustomSectionView.swift; sourceTree = "<group>"; };
B7728AA8046D460145EAC740 /* RoomTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTests.swift; sourceTree = "<group>"; };
B7884BD256C091EB511B2EDF /* AppLockSetupPINScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModelProtocol.swift; sourceTree = "<group>"; };
B788615712FED326F73D3F83 /* GlobalSearchScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModelProtocol.swift; sourceTree = "<group>"; };
B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsSignalling.swift; sourceTree = "<group>"; };
B80D1901BA0B095E27793EDE /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = "<group>"; };
@@ -2965,7 +2964,6 @@
E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = "<group>"; };
E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = "<group>"; };
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchScreenViewModelTests.swift; sourceTree = "<group>"; };
EA551A98778CEE7366838CE2 /* QRCodeLoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenCoordinator.swift; sourceTree = "<group>"; };
EA880E78AF4BD24E45A7808C /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/InfoPlist.strings; sourceTree = "<group>"; };
EACD4855BDAD0799FD86B7B5 /* SpaceRoomListProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomListProxyProtocol.swift; sourceTree = "<group>"; };
@@ -4019,15 +4017,6 @@
path = LegalInformationScreen;
sourceTree = "<group>";
};
390C92506AD893EC593D405D /* View */ = {
isa = PBXGroup;
children = (
24B8177BD2AF45A286F5DA31 /* GlobalSearchScreen.swift */,
22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */,
);
path = View;
sourceTree = "<group>";
};
39557ADF21345E18F3865B9E /* Emojis */ = {
isa = PBXGroup;
children = (
@@ -4739,6 +4728,14 @@
path = View;
sourceTree = "<group>";
};
62E6DBFA3C4FCB80E2B490CD /* View */ = {
isa = PBXGroup;
children = (
A79A78330B9329E3A99E4105 /* SearchScreen.swift */,
);
path = View;
sourceTree = "<group>";
};
638665D7260F974BA9A3BFA1 /* TestUtilities */ = {
isa = PBXGroup;
children = (
@@ -4826,6 +4823,18 @@
name = Products;
sourceTree = "<group>";
};
68E0BC14DDA20EB062EBDE95 /* SearchScreen */ = {
isa = PBXGroup;
children = (
3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */,
0CD63A653BCA2C8112654B19 /* SearchScreenModels.swift */,
1C4724D5CB16CFA5853EF8F9 /* SearchScreenViewModel.swift */,
44C70BD39FC34A9FD2467540 /* SearchScreenViewModelProtocol.swift */,
62E6DBFA3C4FCB80E2B490CD /* View */,
);
path = SearchScreen;
sourceTree = "<group>";
};
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 = "<group>";
};
8A4738BBA7C7A299BAD70372 /* GlobalSearchScreen */ = {
isa = PBXGroup;
children = (
74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */,
436A0D98D372B17EAE9AA999 /* GlobalSearchScreenModels.swift */,
38354164AF59C5006CD05878 /* GlobalSearchScreenViewModel.swift */,
B788615712FED326F73D3F83 /* GlobalSearchScreenViewModelProtocol.swift */,
390C92506AD893EC593D405D /* View */,
);
path = GlobalSearchScreen;
sourceTree = "<group>";
};
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 */,
@@ -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.";
@@ -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])
}
@@ -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 {
@@ -23,17 +23,21 @@ import SwiftUI
let title: String
let icon: KeyPath<CompoundIcons, Image>
let selectedIcon: KeyPath<CompoundIcons, Image>
/// 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<CompoundIcons, Image>, selectedIcon: KeyPath<CompoundIcons, Image>) {
init(tag: Tag, title: String, icon: KeyPath<CompoundIcons, Image>, selectedIcon: KeyPath<CompoundIcons, Image>, 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<Tag: Hashable>: 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()
@@ -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)
@@ -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) {
@@ -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 }
@@ -43,9 +43,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - retaining purpose
private var startChatFlowCoordinator: StartChatFlowCoordinator?
// periphery:ignore - retaining purpose
private var globalSearchScreenCoordinator: GlobalSearchScreenCoordinator?
private var cancellables = Set<AnyCancellable>()
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
@@ -172,11 +169,9 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.processEvent(.presentTransferOwnershipScreen(roomID: roomID))
}
case .globalSearch:
presentGlobalSearch()
case .chatBackupSettings:
actionsSubject.send(.showChatBackupSettings)
case .accountProvisioningLink, .oAuthCallback, .settings, .call:
case .accountProvisioningLink, .oAuthCallback, .settings, .call, .search:
break // These routes cannot be handled.
}
}
@@ -720,45 +715,6 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
}
}
// MARK: Global search
private func presentGlobalSearch() {
let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider
let coordinator = GlobalSearchScreenCoordinator(parameters: .init(roomSummaryProvider: roomSummaryProvider,
mediaProvider: userSession.mediaProvider))
globalSearchScreenCoordinator = coordinator
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
dismissGlobalSearch()
case .select(let roomID):
dismissGlobalSearch()
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
actionsSubject.send(.switchToChatsTab)
}
}
.store(in: &cancellables)
let hostingController = UIHostingController(rootView: coordinator.toPresentable())
hostingController.view.backgroundColor = .clear
flowParameters.windowManager.globalSearchWindow.rootViewController = hostingController
flowParameters.windowManager.showGlobalSearch()
}
private func dismissGlobalSearch() {
flowParameters.windowManager.globalSearchWindow.rootViewController = nil
flowParameters.windowManager.hideGlobalSearch()
globalSearchScreenCoordinator = nil
}
// MARK: Room Directory Search
private func presentRoomDirectorySearch() {
@@ -89,7 +89,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
.roomDetails, .roomMemberDetails, .userProfile, .thread,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.call, .settings, .share, .transferOwnership,
.globalSearch:
.search:
// These routes aren't in this flow so clear the entire stack.
clearRoute(animated: animated)
case .chatBackupSettings:
@@ -208,7 +208,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above.
case .accountProvisioningLink, .oAuthCallback, .roomList, .userProfile, .call, .settings, .chatBackupSettings, .globalSearch:
case .accountProvisioningLink, .oAuthCallback, .roomList, .userProfile, .call, .settings, .chatBackupSettings, .search:
break // These routes can't be handled.
case .transferOwnership(let roomID):
guard self.roomID == roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
@@ -123,7 +123,7 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
case .accountProvisioningLink, .oAuthCallback,
.roomList, .room, .roomDetails, .event,
.userProfile, .call, .settings, .chatBackupSettings,
.share, .transferOwnership, .thread, .globalSearch:
.share, .transferOwnership, .thread, .search:
break
}
}
@@ -20,7 +20,7 @@ enum UserSessionFlowCoordinatorAction {
}
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
enum HomeTab: Hashable { case chats, spaces }
enum HomeTab: Hashable { case chats, spaces, search }
private let navigationRootCoordinator: NavigationRootCoordinator
private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
@@ -38,6 +38,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let spacesTabFlowCoordinator: SpacesTabFlowCoordinator
private let spacesTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails
private let searchScreenCoordinator: SearchScreenCoordinator?
private let searchTabNavigationStackCoordinator: NavigationStackCoordinator?
private let searchTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails?
// periphery:ignore - retaining purpose
private var settingsFlowCoordinator: SettingsFlowCoordinator?
@@ -92,16 +96,35 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
spacesTabDetails = .init(tag: HomeTab.spaces, title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid)
spacesTabDetails.navigationSplitCoordinator = spacesSplitCoordinator
if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) {
let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
mediaProvider: flowParameters.userSession.mediaProvider))
let searchStackCoordinator = NavigationStackCoordinator()
searchStackCoordinator.setRootCoordinator(searchCoordinator)
searchScreenCoordinator = searchCoordinator
searchTabNavigationStackCoordinator = searchStackCoordinator
searchTabDetails = .init(tag: HomeTab.search, title: UntranslatedL10n.screenHomeTabSearch, icon: \.search, selectedIcon: \.search, isSearch: true)
} else {
searchScreenCoordinator = nil
searchTabNavigationStackCoordinator = nil
searchTabDetails = nil
}
onboardingStackCoordinator = NavigationStackCoordinator()
onboardingFlowCoordinator = OnboardingFlowCoordinator(isNewLogin: isNewLogin,
appLockService: appLockService,
navigationStackCoordinator: onboardingStackCoordinator,
flowParameters: flowParameters)
navigationTabCoordinator.setTabs([
var tabs: [NavigationTabCoordinator<HomeTab>.Tab] = [
.init(coordinator: chatsSplitCoordinator, details: chatsTabDetails),
.init(coordinator: spacesSplitCoordinator, details: spacesTabDetails)
])
]
if let searchTabNavigationStackCoordinator, let searchTabDetails {
tabs.append(.init(coordinator: searchTabNavigationStackCoordinator, details: searchTabDetails))
}
navigationTabCoordinator.setTabs(tabs)
stateMachine = flowParameters.stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial)
configureStateMachine()
@@ -137,12 +160,17 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.share, .transferOwnership, .thread, .globalSearch:
.share, .transferOwnership, .thread:
clearPresentedSheets(animated: animated) // Make sure the presented route is visible.
chatsTabFlowCoordinator.handleAppRoute(appRoute, animated: animated)
if navigationTabCoordinator.selectedTab != .chats {
navigationTabCoordinator.selectedTab = .chats
}
case .search:
// Switch to the dedicated search tab when it's available (iOS 26 + flag), otherwise ignore.
if searchTabNavigationStackCoordinator != nil {
navigationTabCoordinator.selectedTab = .search
}
}
}
@@ -195,6 +223,16 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
private func setupObservers() {
searchScreenCoordinator?.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentRoom(let roomID):
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
}
}
.store(in: &cancellables)
chatsTabFlowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
@@ -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") }
@@ -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()
+2 -2
View File
@@ -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,
@@ -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,
@@ -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<AnyCancellable>()
private let actionsSubject: PassthroughSubject<GlobalSearchControllerAction, Never> = .init()
var actions: AnyPublisher<GlobalSearchControllerAction, Never> {
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()
}
}
@@ -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
}
@@ -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<GlobalSearchScreenViewState, GlobalSearchScreenViewAction>
class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearchScreenViewModelProtocol {
private let roomSummaryProvider: RoomSummaryProviderProtocol
private var actionsSubject: PassthroughSubject<GlobalSearchScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<GlobalSearchScreenViewModelAction, Never> {
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..<roomCount)
default:
break
}
}
}
@@ -1,16 +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
protocol GlobalSearchScreenViewModelProtocol {
var actions: AnyPublisher<GlobalSearchScreenViewModelAction, Never> { get }
var context: GlobalSearchScreenViewModelType.Context { get }
func stop()
}
@@ -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<String>
let endEditingHandler: () -> Void
init(text: Binding<String>, 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<UIPress>, 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)
}
}
}
@@ -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)
}
}
}
@@ -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
@@ -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)
@@ -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..<lastIndex))
}
}
private extension View {
@ViewBuilder
func roomListSearchable(isEnabled: Bool, isSearchFieldFocused: Binding<Bool>, searchQuery: Binding<String>) -> some View {
if isEnabled {
isSearching(isSearchFieldFocused)
.searchable(text: searchQuery, placement: .navigationBarDrawer(displayMode: .always))
.compoundSearchField()
.disableAutocorrection(true)
} else {
self
}
}
}
@@ -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<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SearchScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<SearchScreenCoordinatorAction, Never> {
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))
}
}
@@ -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
}
@@ -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<SearchScreenViewState, SearchScreenViewAction>
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
private let roomSummaryProvider: RoomSummaryProviderProtocol
private var searchQueryObservationTask: Task<Void, Never>?
private let actionsSubject: PassthroughSubject<SearchScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
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..<roomCount)
default:
break
}
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
@MainActor
protocol SearchScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> { get }
var context: SearchScreenViewModelType.Context { get }
}
@@ -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<UIPress>, 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")
}
}
@@ -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 { }
@@ -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()
@@ -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,
@@ -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() {
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2e96b757eacb7be702a40a8f9494f8900e2ada38838c78a38190a3837d56a2ec
size 171848
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:456fe50194ca5e15d431437a6342bc0664266cdc54d8084dd18639980cf0eb60
size 172156
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:611812894a3f67b80d5f32a9192eb787262c4c05fb869a79ceb561ac88865047
size 106611
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80c1dffec6a31d5383f694ad063dbb1f6614f4c8da21b94e8d4b587fa355b706
size 106942
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b9d73c339d27730080a8620d3c3dfee9e8bb05f22a911d30a57bcab71fb18a1
size 98755
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b9d73c339d27730080a8620d3c3dfee9e8bb05f22a911d30a57bcab71fb18a1
size 98755
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b47056a891a64a3c4a5ead1161a98ca8e16319727cb8962f95f8a8ddc47e2a45
size 50155
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b47056a891a64a3c4a5ead1161a98ca8e16319727cb8962f95f8a8ddc47e2a45
size 50155
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a93a6802d8a053abd390e9311921294dfbab18844eb17a63eaf4b8c623173a9
size 85119
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72835c30b6f459759cbbae44aaba4aed0c39674c49879602c7a8a319323b9db1
size 88205
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:25f1c159081bfcf7e1fb181bb6f1ee2f614a1ae3759052cc5dac3f7266b271ba
size 42662
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:513a8d3815582253be1eecf22a7726c7917c9e83e7465aa5e7b90ef2d7944c1e
size 49713
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69ddd3c435d8c50a231bdc128c059127219d35624a16912579ce0f878830a274
size 148724
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:680e82d9b7877d868e734783ac7d9dc9c615d7c53a80b816e7529cbda6e3be0d
size 149586
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a419004009267be00c3bb862408e82639e2fb45f6951e77da9309b1245f046d
size 103648
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a6a0b062ae788f675ada0f9011ff70dea366bf36c6b5d5a13a5bd909a03bc6c
size 104605
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d6cd51861f45a9e393eb3194ee7224a094646ce4385f2aa8ecb1be404bbaa6d
size 87898
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c96db7daacd17e38fb6d0d74cc9de51f52b0c17fb2c02c2c162d8384ca1ab6d2
size 94243
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31369bff674e454cb3cacdcb7a33efe035c838e4d09b549ba07979597d74a744
size 49368
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7cc94abaa65358e0cf82a0635b0aaa5f7858f0a765b3ddf0f8b647cba493cfe6
size 58249
@@ -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()
}
}
@@ -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()
}
}