mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Combine the Server Selection screen into the Server Confirmation screen and add autocompletion when entering an account provider. (#5949)
* Adjust login flow to exclusively use the ServerSelectionScreen * Move async code to server selection vm * ServerSelection supports pro picker options * Use homeserver address for both confirmation and picker modes * rename ServerSelectionScreenMode * remove ServerConfirmationScreen It's been superseded by the ServerSelectionScreen * rename userInput * Update strings with latest design strings * Add server input autocompletion * Store previous servers in AppSettings and autofill on server signin * Audit changes for logging * Enforce lowercase server history storage * Update tests * Update Xcode project * PR review fixups * Add HomeserverHistoryManager * Review fixups 2 * Review fixups 3
This commit is contained in:
@@ -659,10 +659,6 @@ extension AccessibilityTests {
|
||||
try await performAccessibilityAudit(named: "SeparatorRoomTimelineView_Previews")
|
||||
}
|
||||
|
||||
func testServerConfirmationScreen() async throws {
|
||||
try await performAccessibilityAudit(named: "ServerConfirmationScreen_Previews")
|
||||
}
|
||||
|
||||
func testServerSelection() async throws {
|
||||
try await performAccessibilityAudit(named: "ServerSelection_Previews")
|
||||
}
|
||||
|
||||
@@ -300,7 +300,6 @@
|
||||
2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; };
|
||||
2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; };
|
||||
2E6AD068D7767BDC33626F1C /* SnapshotableGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */; };
|
||||
2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */; };
|
||||
2EAA1B35D9CA24F090F48792 /* SpaceRoomListProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACD4855BDAD0799FD86B7B5 /* SpaceRoomListProxyProtocol.swift */; };
|
||||
2F2906AE9BC3D0E79A6F98F8 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; };
|
||||
2F6207CB5C4715FE313B1E95 /* TimelineViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6509708F54FC883604DFDC95 /* TimelineViewModelTests.swift */; };
|
||||
@@ -326,6 +325,7 @@
|
||||
33CAC1226DFB8B5D8447D286 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; };
|
||||
33F1FB19F222BA9930AB1A00 /* RoomListFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6372DD10DED30E7AD7BCE21 /* RoomListFiltersView.swift */; };
|
||||
340D39DB87F3800D53A6A621 /* EmojiPickerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */; };
|
||||
34320DB3C585FD34F4DEC5C8 /* HomeserverHistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */; };
|
||||
34357B287357BC0B9715DD51 /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */; };
|
||||
34390DAE0C574DAD30CCA7D9 /* DeclineAndBlockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ADF12A50186B75C68017B61 /* DeclineAndBlockScreenViewModelTests.swift */; };
|
||||
34ADC506C929D10B138EBA51 /* AdvancedSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C58159916ABD7F454CE689 /* AdvancedSettingsScreenCoordinator.swift */; };
|
||||
@@ -397,7 +397,6 @@
|
||||
3F55721B5C08E8D9F1295592 /* SDKListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5CD2993048222B64C45006 /* SDKListener.swift */; };
|
||||
3F70E237CE4C3FAB02FC227F /* NotificationConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C830A64609CBD152F06E0457 /* NotificationConstants.swift */; };
|
||||
3F791C4E0F7B373DB217EC90 /* NSItemProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ED859C6363DAB2E8CFF2CE /* NSItemProviderTests.swift */; };
|
||||
401BB28CD6B7DD6B4E7863E7 /* ServerConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */; };
|
||||
4060D5B90302C93F5617B290 /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = 45CF0AEC68C170C55A613370 /* Macros */; };
|
||||
407DCE030E0F9B7C9861D38A /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 81DB3AB6CE996AB3954F4F03 /* KZFileWatchers */; };
|
||||
40839665086F5DAFA55D0EED /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; };
|
||||
@@ -513,7 +512,6 @@
|
||||
52BE121FC21CE88803D2D8B5 /* GalleryItemTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7927284C6F70D60BE5949F8C /* GalleryItemTileView.swift */; };
|
||||
5341D48F833E3E30F16FA2A3 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2910422CB628D3B2BBE47449 /* SeparatorRoomTimelineView.swift */; };
|
||||
5375902175B2FEA2949D7D74 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */; };
|
||||
53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */; };
|
||||
53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9CB3B9DFA353AB2B7CD9F8 /* NotificationSettingsEditScreenCoordinator.swift */; };
|
||||
53C1E7F6A7D6409D89F36ED7 /* AggregatedReactionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */; };
|
||||
53DEF39F0C4DE02E3FC56D91 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 800631D7250B7F93195035F1 /* KeychainAccess */; };
|
||||
@@ -547,7 +545,6 @@
|
||||
585DCA0487A0A6F4E59EF5CA /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353024006CB726E9F9187B3A /* AnalyticsConsentState.swift */; };
|
||||
5877255D6E6ED898D402ED8D /* LocationPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408ACC0D28656F82A5EB6A7E /* LocationPickerSheet.swift */; };
|
||||
588411C8FD72B2A2DFE5F7DE /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E992D7B8BE54B2AB454613AF /* XCUIElement.swift */; };
|
||||
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 */; };
|
||||
@@ -705,6 +702,7 @@
|
||||
71AF9345A1202DB9B5DCD9AE /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = AF574A0FC6F3A83D3BFFC9B3 /* Macros */; };
|
||||
71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; };
|
||||
71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; };
|
||||
723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */; };
|
||||
7254FB2EFDD43BC8BB7A1213 /* SecurityAndPrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AE42C19EDE64B7CB7BE4D0 /* SecurityAndPrivacyScreen.swift */; };
|
||||
726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */; };
|
||||
72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; };
|
||||
@@ -857,7 +855,6 @@
|
||||
890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; };
|
||||
89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; };
|
||||
8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; };
|
||||
89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */; };
|
||||
899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; };
|
||||
89B909AC66B96FA054EF3C14 /* InvitedRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E95B3BDB80531C85CD50AE6 /* InvitedRoomProxy.swift */; };
|
||||
89DF67AECBF9D0EE0DDB7737 /* Tracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B574805B9812C111D6215D /* Tracing.swift */; };
|
||||
@@ -1427,6 +1424,7 @@
|
||||
E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; };
|
||||
E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; };
|
||||
E8F88136C8E69D9E69621E19 /* ContentScannerServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */; };
|
||||
E917AA8ED28D706648102047 /* TextFieldAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */; };
|
||||
E9347F56CF0683208F4D9249 /* RoomNotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A9B5225D0881CEFA2CF7C9 /* RoomNotificationSettingsScreenViewModel.swift */; };
|
||||
E938F7A45D6A3DBBE6789A03 /* NSEUserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9651CD1066F239C7739240 /* NSEUserSession.swift */; };
|
||||
E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */; };
|
||||
@@ -1478,7 +1476,6 @@
|
||||
F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -1559,7 +1556,6 @@
|
||||
FD19654C18E7C6D8AC79408D /* libMatrixRustSDKMocks.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */; };
|
||||
FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; };
|
||||
FD573B5D665824EB79EABF06 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */; };
|
||||
FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; };
|
||||
FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; };
|
||||
FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */; };
|
||||
FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */; };
|
||||
@@ -1679,7 +1675,6 @@
|
||||
02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
02EE0FABA8ED6D6C1D6CE71D /* ReactionsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsSummaryView.swift; sourceTree = "<group>"; };
|
||||
0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAndPrivacyScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
0328F54E0C3AAEDDF3E05D9D /* ChatsTabFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsTabFlowCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
033DB41C51865A2E83174E87 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
|
||||
035177BCD8E8308B098AC3C2 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = "<group>"; };
|
||||
@@ -1892,7 +1887,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>"; };
|
||||
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>"; };
|
||||
24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -1909,7 +1903,6 @@
|
||||
2721D7B051F0159AA919DA05 /* RoomChangePermissionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = "<group>"; };
|
||||
276833816F5DEB1CE3B8BE1E /* ReportRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreen.swift; sourceTree = "<group>"; };
|
||||
277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigurationScreenViewStateTests.swift; sourceTree = "<group>"; };
|
||||
27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = "<group>"; };
|
||||
27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFixtures.swift; sourceTree = "<group>"; };
|
||||
27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = "<group>"; };
|
||||
@@ -2290,6 +2283,7 @@
|
||||
6C9651CD1066F239C7739240 /* NSEUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEUserSession.swift; sourceTree = "<group>"; };
|
||||
6CD4823EAB4B4E8BAB4F6B8C /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = "<group>"; };
|
||||
6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsScreenIdentifier.swift; sourceTree = "<group>"; };
|
||||
6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverHistoryManager.swift; sourceTree = "<group>"; };
|
||||
6DB53055CB130F0651C70763 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatViewModelTests.swift; sourceTree = "<group>"; };
|
||||
6E5E9C044BEB7C70B1378E91 /* UserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = "<group>"; };
|
||||
@@ -2384,6 +2378,7 @@
|
||||
7C28B70BEFD3676F11D5D51F /* RoomRolesAndPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
7C71B9802433F1B4252291BB /* IdentityConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldAdapter.swift; sourceTree = "<group>"; };
|
||||
7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||
7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactoryProtocol.swift; sourceTree = "<group>"; };
|
||||
7D39AF1F659923D77778511E /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@@ -2518,7 +2513,6 @@
|
||||
92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScannerView.swift; sourceTree = "<group>"; };
|
||||
9332DFE9642F0A46ECA0497B /* BlurHashEncode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; };
|
||||
933B074F006F8E930DB98B4E /* TimelineMediaFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaFrame.swift; sourceTree = "<group>"; };
|
||||
9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenModels.swift; sourceTree = "<group>"; };
|
||||
9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = "<group>"; };
|
||||
93C713D124FE915ABF47A6B7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
||||
93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
@@ -2658,7 +2652,6 @@
|
||||
AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = "<group>"; };
|
||||
AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommonSettings+NotificationName.swift"; sourceTree = "<group>"; };
|
||||
AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaContentScanningFailureView.swift; sourceTree = "<group>"; };
|
||||
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = "<group>"; };
|
||||
AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = "<group>"; };
|
||||
@@ -2942,6 +2935,7 @@
|
||||
DB08D1F7C27A8C24EF81073C /* MapURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapURLs.swift; sourceTree = "<group>"; };
|
||||
DB67A042079F7B250A62C8FB /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/SAS.strings; sourceTree = "<group>"; };
|
||||
DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemKeyForwarder.swift; sourceTree = "<group>"; };
|
||||
DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverHistoryManagerTests.swift; sourceTree = "<group>"; };
|
||||
DBEDCEC9D908C19C63D24395 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = "<group>"; };
|
||||
DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -3068,7 +3062,6 @@
|
||||
F012CB5EE3F2B67359F6CC52 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
|
||||
F0205C03F98BE861EDABCB0D /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F03AEAA2F66E796C365EFD58 /* ElementNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementNavigationStack.swift; sourceTree = "<group>"; };
|
||||
F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
F0E14FF533D25A0692F7CEB0 /* RoomPollsHistoryScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
F1033290D99D5BBA1AF3560A /* MapTilerURLBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerURLBuilderProtocol.swift; sourceTree = "<group>"; };
|
||||
F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSelectionScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -3108,7 +3101,6 @@
|
||||
F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = "<group>"; };
|
||||
F64A8582F65567AC38C2976A /* PollFormScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = "<group>"; };
|
||||
F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreen.swift; sourceTree = "<group>"; };
|
||||
F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackSpeed.swift; sourceTree = "<group>"; };
|
||||
F7E247E29C7A0E421DB839D7 /* RoomPowerLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevel.swift; sourceTree = "<group>"; };
|
||||
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
@@ -3766,14 +3758,6 @@
|
||||
path = RoomMember;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2CAAA563083EAC0FA6BDC4F3 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -5074,6 +5058,7 @@
|
||||
1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */,
|
||||
B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */,
|
||||
505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */,
|
||||
DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */,
|
||||
ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */,
|
||||
845DDBDE5A0887E73D38B826 /* InviteUsersViewModelTests.swift */,
|
||||
DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */,
|
||||
@@ -5136,8 +5121,6 @@
|
||||
5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */,
|
||||
848F69921527D31CAACB93AF /* SecureBackupLogoutConfirmationScreenViewModelTests.swift */,
|
||||
0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */,
|
||||
277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */,
|
||||
F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */,
|
||||
E45EBAFF1A83538D54ABDF92 /* ServerSelectionScreenViewModelTests.swift */,
|
||||
0825EAFD47332DD459DE893F /* SessionDirectoriesTests.swift */,
|
||||
A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */,
|
||||
@@ -5418,6 +5401,7 @@
|
||||
0724EBDFE8BB4C9E5547C57D /* Client.swift */,
|
||||
18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */,
|
||||
6033779EB37259F27F938937 /* ClientProxyProtocol.swift */,
|
||||
6D2BBDB7E80E119D5C906285 /* HomeserverHistoryManager.swift */,
|
||||
);
|
||||
path = Client;
|
||||
sourceTree = "<group>";
|
||||
@@ -6321,18 +6305,6 @@
|
||||
path = RoomMemberDetailsScreen;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BA1938A75D8C780F694CEB62 /* ServerConfirmationScreen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */,
|
||||
9342F5D6729627B6393AF853 /* ServerConfirmationScreenModels.swift */,
|
||||
24DEE0682C95F897B6C7CB0D /* ServerConfirmationScreenViewModel.swift */,
|
||||
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */,
|
||||
2CAAA563083EAC0FA6BDC4F3 /* View */,
|
||||
);
|
||||
path = ServerConfirmationScreen;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB11CF5847CA0B3BC3A7F8DA /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -6415,6 +6387,7 @@
|
||||
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */,
|
||||
FE5CD2993048222B64C45006 /* SDKListener.swift */,
|
||||
4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */,
|
||||
7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */,
|
||||
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */,
|
||||
7431C962E314ADAE38B6D708 /* Analytics */,
|
||||
9F7C2D63C42828D8931D5286 /* CollapsibleFlowLayout */,
|
||||
@@ -6992,7 +6965,6 @@
|
||||
AEA11EF88B2848B12FA6FA36 /* OAuthAuthenticationPresenter.swift */,
|
||||
9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */,
|
||||
90F48FEF84016ED42A94BA24 /* LoginScreen */,
|
||||
BA1938A75D8C780F694CEB62 /* ServerConfirmationScreen */,
|
||||
2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */,
|
||||
5B2C520AB9863B8CBC8EB3CA /* SoftLogoutScreen */,
|
||||
A3C1613EC54531E5BC4CC835 /* StartScreen */,
|
||||
@@ -7660,16 +7632,16 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
C0FAEB81CFD9776CD78CE489 /* ElementX */,
|
||||
FEB53A5BC378C913769656D8 /* NSE */,
|
||||
19F0C845D67E9BEA4BE7133E /* ShareExtension */,
|
||||
676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */,
|
||||
32C23C8D224D46EFE62AFAD0 /* UnitTests */,
|
||||
7A17BE29BAC81ADBAC6349D9 /* PreviewTests */,
|
||||
0E28CD62691FDBC63147D5E3 /* UITests */,
|
||||
D3DB351B7FBE0F49649171FC /* IntegrationTests */,
|
||||
C0C687DE1D270F9895FEE186 /* AccessibilityTests */,
|
||||
C0FAEB81CFD9776CD78CE489 /* ElementX */,
|
||||
D3DB351B7FBE0F49649171FC /* IntegrationTests */,
|
||||
676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */,
|
||||
FEB53A5BC378C913769656D8 /* NSE */,
|
||||
F8E276FD6DC43EADB85241BC /* Periphery */,
|
||||
7A17BE29BAC81ADBAC6349D9 /* PreviewTests */,
|
||||
19F0C845D67E9BEA4BE7133E /* ShareExtension */,
|
||||
0E28CD62691FDBC63147D5E3 /* UITests */,
|
||||
32C23C8D224D46EFE62AFAD0 /* UnitTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -8078,6 +8050,7 @@
|
||||
07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */,
|
||||
6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */,
|
||||
F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */,
|
||||
723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */,
|
||||
00091F143129B9A4904563C4 /* IdentityConfirmationScreenViewModelTests.swift in Sources */,
|
||||
A23B8B27A1436A1049EEF68E /* InfoPlistReader.swift in Sources */,
|
||||
A216C83ADCF32BA5EF8A6FBC /* InviteUsersViewModelTests.swift in Sources */,
|
||||
@@ -8150,8 +8123,6 @@
|
||||
D69CCEC785E6C819551B402F /* SearchScreenViewModelTests.swift in Sources */,
|
||||
EB87DF90CF6F8D5D12404C6E /* SecureBackupLogoutConfirmationScreenViewModelTests.swift in Sources */,
|
||||
CB9FB2BEF313072C705AC9B5 /* SecurityAndPrivacyScreenViewModelTests.swift in Sources */,
|
||||
53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */,
|
||||
89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */,
|
||||
67ECD32538F6DAFE38A623F9 /* ServerSelectionScreenViewModelTests.swift in Sources */,
|
||||
CC1C948F67A5510A340FD7F0 /* SessionDirectoriesTests.swift in Sources */,
|
||||
86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */,
|
||||
@@ -8596,6 +8567,7 @@
|
||||
56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */,
|
||||
C0A845AAFEB7E1BEAE04306E /* HomeserverCapabilitiesProxy.swift in Sources */,
|
||||
97550ECE8A1D28FEF74FBACC /* HomeserverCapabilitiesProxyProtocol.swift in Sources */,
|
||||
34320DB3C585FD34F4DEC5C8 /* HomeserverHistoryManager.swift in Sources */,
|
||||
277FD4394EAFF323DA34997E /* HorizontalHighlightGradient.swift in Sources */,
|
||||
2BBE320EE426A347AAE5C7DA /* IdentityConfirmationScreen.swift in Sources */,
|
||||
C3BB6887CF13B19182E81F87 /* IdentityConfirmationScreenCoordinator.swift in Sources */,
|
||||
@@ -9105,11 +9077,6 @@
|
||||
08547E55DD3686A84550996D /* SeparatorMediaEventsTimelineView.swift in Sources */,
|
||||
14E99D27628B1A6F0CB46FEA /* SeparatorRoomTimelineItem.swift in Sources */,
|
||||
5341D48F833E3E30F16FA2A3 /* SeparatorRoomTimelineView.swift in Sources */,
|
||||
2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */,
|
||||
5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */,
|
||||
401BB28CD6B7DD6B4E7863E7 /* ServerConfirmationScreenModels.swift in Sources */,
|
||||
F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */,
|
||||
FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */,
|
||||
43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */,
|
||||
E5F4C992845388B50BABACAA /* ServerSelectionScreenCoordinator.swift in Sources */,
|
||||
6530865EB9A8C0F0AF0216DA /* ServerSelectionScreenModels.swift in Sources */,
|
||||
@@ -9212,6 +9179,7 @@
|
||||
1ECE584D2CFA0FEF0ADE458C /* TestablePreviewsDictionary.swift in Sources */,
|
||||
887AC93C523AEFB640EA5EC8 /* TextBasedRoomTimelineItem.swift in Sources */,
|
||||
4FE688FE9375B2FBF424146A /* TextBasedRoomTimelineViewProtocol.swift in Sources */,
|
||||
E917AA8ED28D706648102047 /* TextFieldAdapter.swift in Sources */,
|
||||
BB784A02BADB03C820617A46 /* TextRoomTimelineItem.swift in Sources */,
|
||||
53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */,
|
||||
D8CFA0EE46376F9FF04EEE45 /* TextRoomTimelineView.swift in Sources */,
|
||||
|
||||
@@ -182,6 +182,14 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
||||
@UserPreference(defaultValue: AppAppearance.system)
|
||||
var appAppearance: AppAppearance
|
||||
|
||||
/// Tracks previous servers the user connected to for autocompletion purposes. Entries are made lowercase on write.
|
||||
@UserPreference(key: "previousServers", defaultValue: [])
|
||||
var previousServers: [String]
|
||||
|
||||
var defaultServer: String {
|
||||
previousServers.first ?? accountProviders[0]
|
||||
}
|
||||
|
||||
// MARK: - Security
|
||||
|
||||
/// The app must be locked with a PIN code as part of the authentication flow.
|
||||
|
||||
@@ -21,6 +21,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let homeserverHistoryManager: HomeserverHistoryManager
|
||||
private let appHooks: AppHooks
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
@@ -34,10 +35,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
/// The screen used for the whole QR Code flow.
|
||||
case qrCodeLoginScreen
|
||||
|
||||
/// The screen to continue authentication with the current server.
|
||||
case serverConfirmationScreen
|
||||
/// The screen to choose a different server.
|
||||
/// The screen used to choose a server
|
||||
case serverSelectionScreen
|
||||
|
||||
/// The screen to login with a password.
|
||||
case loginScreen
|
||||
|
||||
@@ -60,17 +60,12 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
/// The user would like to login with a QR code.
|
||||
case loginWithQR
|
||||
/// Show the server confirmation screen.
|
||||
case confirmServer(AuthenticationFlow)
|
||||
case selectServer(AuthenticationFlow)
|
||||
|
||||
/// The QR login flow was aborted.
|
||||
case cancelledLoginWithQR
|
||||
/// The user aborted manual login.
|
||||
case cancelledServerConfirmation
|
||||
|
||||
/// The user would like to enter a different server.
|
||||
case changeServer(AuthenticationFlow)
|
||||
/// The user is no longer selecting a server.
|
||||
case dismissedServerSelection
|
||||
case cancelledServerSelection
|
||||
|
||||
/// Show the screen to login with password (with the optional login hint in the `userInfo`).
|
||||
case continueWithPassword
|
||||
@@ -113,6 +108,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
self.navigationRootCoordinator = navigationRootCoordinator
|
||||
self.appMediator = appMediator
|
||||
self.appSettings = appSettings
|
||||
homeserverHistoryManager = HomeserverHistoryManager(appSettings: appSettings)
|
||||
self.appHooks = appHooks
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
@@ -155,10 +151,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .qrCodeLoginScreen:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually.
|
||||
case .serverConfirmationScreen:
|
||||
navigationStackCoordinator.popToRoot(animated: animated)
|
||||
case .serverSelectionScreen:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
navigationStackCoordinator.popToRoot(animated: animated)
|
||||
case .loginScreen:
|
||||
navigationStackCoordinator.popToRoot(animated: animated)
|
||||
@@ -202,28 +195,20 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// Manual Authentication
|
||||
|
||||
stateMachine.addRoutes(event: .confirmServer(.login), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
|
||||
self?.showServerConfirmationScreen(authenticationFlow: .login)
|
||||
}
|
||||
stateMachine.addRoutes(event: .confirmServer(.register), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
|
||||
self?.showServerConfirmationScreen(authenticationFlow: .register)
|
||||
}
|
||||
stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen])
|
||||
|
||||
stateMachine.addRoutes(event: .changeServer(.login), transitions: [.serverConfirmationScreen => .serverSelectionScreen]) { [weak self] _ in
|
||||
stateMachine.addRoutes(event: .selectServer(.login), transitions: [.startScreen => .serverSelectionScreen]) { [weak self] _ in
|
||||
self?.showServerSelectionScreen(authenticationFlow: .login)
|
||||
}
|
||||
stateMachine.addRoutes(event: .changeServer(.register), transitions: [.serverConfirmationScreen => .serverSelectionScreen]) { [weak self] _ in
|
||||
stateMachine.addRoutes(event: .selectServer(.register), transitions: [.startScreen => .serverSelectionScreen]) { [weak self] _ in
|
||||
self?.showServerSelectionScreen(authenticationFlow: .register)
|
||||
}
|
||||
stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen])
|
||||
stateMachine.addRoutes(event: .cancelledServerSelection, transitions: [.serverSelectionScreen => .startScreen])
|
||||
|
||||
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen,
|
||||
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverSelectionScreen => .loginScreen,
|
||||
.startScreen => .loginScreen]) { [weak self] context in
|
||||
let loginHint = context.userInfo as? String
|
||||
self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState)
|
||||
}
|
||||
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen])
|
||||
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverSelectionScreen), transitions: [.loginScreen => .serverSelectionScreen])
|
||||
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .startScreen), transitions: [.loginScreen => .startScreen])
|
||||
|
||||
// Bug Report
|
||||
@@ -243,7 +228,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
// Completion
|
||||
|
||||
stateMachine.addRoutes(event: .signedIn, transitions: [.qrCodeLoginScreen => .complete,
|
||||
.serverConfirmationScreen => .complete, // OAuth authentication
|
||||
.serverSelectionScreen => .complete, // OAuth authentication
|
||||
.startScreen => .complete, // Direct OAuth authentication
|
||||
.loginScreen => .complete]) { [weak self] context in
|
||||
guard let userSession = context.userInfo as? UserSessionProtocol else { fatalError("The user session wasn't included in the context") }
|
||||
@@ -292,9 +277,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .loginWithQR:
|
||||
stateMachine.tryEvent(.loginWithQR)
|
||||
case .login:
|
||||
stateMachine.tryEvent(.confirmServer(.login))
|
||||
stateMachine.tryEvent(.selectServer(.login))
|
||||
case .register:
|
||||
stateMachine.tryEvent(.confirmServer(.register))
|
||||
stateMachine.tryEvent(.selectServer(.register))
|
||||
|
||||
case .loginDirectlyWithOAuth(let oAuthData, let window):
|
||||
showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window)
|
||||
@@ -336,7 +321,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .signInManually:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
stateMachine.tryEvent(.cancelledLoginWithQR)
|
||||
stateMachine.tryEvent(.confirmServer(.login))
|
||||
stateMachine.tryEvent(.selectServer(.login))
|
||||
case .signedIn(let userSession):
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
DispatchQueue.main.async {
|
||||
@@ -355,42 +340,15 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// MARK: - Manual Authentication
|
||||
|
||||
private func showServerConfirmationScreen(authenticationFlow: AuthenticationFlow) {
|
||||
private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) {
|
||||
// Reset the service back to the default homeserver before continuing. This ensures
|
||||
// we check that registration is supported if it was previously configured for login.
|
||||
authenticationService.reset()
|
||||
|
||||
let parameters = ServerConfirmationScreenCoordinatorParameters(authenticationService: authenticationService,
|
||||
authenticationFlow: authenticationFlow,
|
||||
appSettings: appSettings,
|
||||
userIndicatorController: userIndicatorController)
|
||||
let coordinator = ServerConfirmationScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .continueWithOAuth(let oAuthData, let window):
|
||||
showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window)
|
||||
case .continueWithPassword:
|
||||
stateMachine.tryEvent(.continueWithPassword)
|
||||
case .changeServer:
|
||||
stateMachine.tryEvent(.changeServer(authenticationFlow))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.cancelledServerConfirmation)
|
||||
}
|
||||
}
|
||||
|
||||
private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) {
|
||||
let navigationCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService,
|
||||
authenticationFlow: authenticationFlow,
|
||||
appSettings: appSettings,
|
||||
homeserverHistoryManager: homeserverHistoryManager,
|
||||
userIndicatorController: userIndicatorController)
|
||||
let coordinator = ServerSelectionScreenCoordinator(parameters: parameters)
|
||||
|
||||
@@ -399,17 +357,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .updated:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .continueWithOAuth(let oAuthData, let window):
|
||||
showOAuthAuthentication(oAuthData: oAuthData, presentationAnchor: window)
|
||||
case .continueWithPassword:
|
||||
stateMachine.tryEvent(.continueWithPassword)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationCoordinator.setRootCoordinator(coordinator)
|
||||
navigationStackCoordinator.setSheetCoordinator(navigationCoordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedServerSelection)
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.cancelledServerSelection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,5 +464,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
private func userHasSignedIn(userSession: UserSessionProtocol) {
|
||||
delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession)
|
||||
|
||||
let newServer = authenticationService.homeserver.value.address
|
||||
homeserverHistoryManager.addServerToList(newServer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,6 @@ enum TestablePreviewsDictionary {
|
||||
"SendInviteConfirmationView_Previews" : SendInviteConfirmationView_Previews.self,
|
||||
"SeparatorMediaEventsTimelineView_Previews" : SeparatorMediaEventsTimelineView_Previews.self,
|
||||
"SeparatorRoomTimelineView_Previews" : SeparatorRoomTimelineView_Previews.self,
|
||||
"ServerConfirmationScreen_Previews" : ServerConfirmationScreen_Previews.self,
|
||||
"SessionVerificationRequestDetailsView_Previews" : SessionVerificationRequestDetailsView_Previews.self,
|
||||
"SessionVerification_Previews" : SessionVerification_Previews.self,
|
||||
"SettingsScreenUserStatusPickerView_Previews" : SettingsScreenUserStatusPickerView_Previews.self,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// 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 UIKit
|
||||
|
||||
/// When leveraging introspection, this allows intercepting UITextFieldDelegate calls
|
||||
class TextFieldAdapter: NSObject, UITextFieldDelegate {
|
||||
var textField: UITextField? {
|
||||
didSet {
|
||||
oldValue?.delegate = nil
|
||||
passthroughDelegate = textField?.delegate
|
||||
textField?.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
/// the original swiftui delegate
|
||||
private var passthroughDelegate: UITextFieldDelegate?
|
||||
|
||||
private let keystrokeSubject = PassthroughSubject<String, Never>()
|
||||
/// fires when keystrokes are made. does not fire for deletions or programmatic text changes
|
||||
var keystrokePublisher: AnyPublisher<String, Never> {
|
||||
keystrokeSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersInRanges ranges: [NSValue], replacementString string: String) -> Bool {
|
||||
guard let range = ranges.first?.rangeValue else { return true }
|
||||
if string.isEmpty == false { // don't fire for backspaces
|
||||
let previousText = textField.text ?? ""
|
||||
|
||||
let completeString = (previousText as NSString).replacingCharacters(in: range, with: string)
|
||||
|
||||
keystrokeSubject.send(completeString)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
self.textField(textField, shouldChangeCharactersInRanges: [NSValue(range: range)], replacementString: string)
|
||||
}
|
||||
|
||||
// MARK: - Passthrough
|
||||
|
||||
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
|
||||
passthroughDelegate?.textFieldShouldBeginEditing?(textField) ?? true
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
passthroughDelegate?.textFieldDidBeginEditing?(textField)
|
||||
}
|
||||
|
||||
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
|
||||
passthroughDelegate?.textFieldShouldEndEditing?(textField) ?? true
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
|
||||
passthroughDelegate?.textFieldDidEndEditing?(textField, reason: reason)
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
passthroughDelegate?.textFieldDidEndEditing?(textField)
|
||||
}
|
||||
|
||||
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||
passthroughDelegate?.textFieldShouldClear?(textField) ?? true
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
passthroughDelegate?.textFieldShouldReturn?(textField) ?? true
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
passthroughDelegate?.textFieldDidChangeSelection?(textField)
|
||||
}
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
func textField(_ textField: UITextField, editMenuForCharactersInRanges ranges: [NSValue], suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
passthroughDelegate?.textField?(textField, editMenuForCharactersInRanges: ranges, suggestedActions: suggestedActions)
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
passthroughDelegate?.textField?(textField, editMenuForCharactersIn: range, suggestedActions: suggestedActions)
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, willPresentEditMenuWith animator: any UIEditMenuInteractionAnimating) {
|
||||
passthroughDelegate?.textField?(textField, willPresentEditMenuWith: animator)
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, willDismissEditMenuWith animator: any UIEditMenuInteractionAnimating) {
|
||||
passthroughDelegate?.textField?(textField, willDismissEditMenuWith: animator)
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, insertInputSuggestion inputSuggestion: UIInputSuggestion) {
|
||||
passthroughDelegate?.textField?(textField, insertInputSuggestion: inputSuggestion)
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +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
|
||||
|
||||
struct ServerConfirmationScreenCoordinatorParameters {
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
let authenticationFlow: AuthenticationFlow
|
||||
let appSettings: AppSettings
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum ServerConfirmationScreenCoordinatorAction {
|
||||
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
|
||||
case continueWithPassword
|
||||
case changeServer
|
||||
}
|
||||
|
||||
final class ServerConfirmationScreenCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: ServerConfirmationScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<ServerConfirmationScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<ServerConfirmationScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: ServerConfirmationScreenCoordinatorParameters) {
|
||||
let mode = if parameters.appSettings.allowOtherAccountProviders {
|
||||
ServerConfirmationScreenMode.confirmation(parameters.authenticationService.homeserver.value.address)
|
||||
} else {
|
||||
ServerConfirmationScreenMode.picker(parameters.appSettings.accountProviders)
|
||||
}
|
||||
|
||||
viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService,
|
||||
mode: mode,
|
||||
authenticationFlow: parameters.authenticationFlow,
|
||||
appSettings: parameters.appSettings,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .continueWithOAuth(let oAuthData, let window):
|
||||
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
|
||||
case .continueWithPassword:
|
||||
actionsSubject.send(.continueWithPassword)
|
||||
case .changeServer:
|
||||
actionsSubject.send(.changeServer)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(ServerConfirmationScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
-102
@@ -1,102 +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 SwiftUI
|
||||
|
||||
enum ServerConfirmationScreenViewModelAction {
|
||||
/// Continue the flow using the provided OAuth parameters.
|
||||
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
|
||||
/// Continue the flow using password authentication.
|
||||
case continueWithPassword
|
||||
/// The user would like to change to a different homeserver.
|
||||
case changeServer
|
||||
}
|
||||
|
||||
enum ServerConfirmationScreenMode: Equatable {
|
||||
/// The user is confirming the displayed account provider (or can enter their own).
|
||||
case confirmation(String)
|
||||
/// The user is only allowed to pick from a list of account providers.
|
||||
case picker([String])
|
||||
}
|
||||
|
||||
struct ServerConfirmationScreenViewState: BindableState {
|
||||
/// Whether the screen is configured to confirm a single account provider or pick one from a list of many.
|
||||
var mode: ServerConfirmationScreenMode
|
||||
/// The flow being attempted on the selected homeserver.
|
||||
let authenticationFlow: AuthenticationFlow
|
||||
/// The presentation anchor used for OAuth authentication.
|
||||
var window: UIWindow?
|
||||
|
||||
var bindings = ServerConfirmationScreenBindings()
|
||||
|
||||
/// The screen's title.
|
||||
var title: String {
|
||||
switch mode {
|
||||
case .confirmation(let accountProvider):
|
||||
switch authenticationFlow {
|
||||
case .login:
|
||||
L10n.screenServerConfirmationTitleLogin(accountProvider)
|
||||
case .register:
|
||||
L10n.screenServerConfirmationTitleRegister(accountProvider)
|
||||
}
|
||||
case .picker:
|
||||
L10n.screenServerConfirmationTitlePickerMode
|
||||
}
|
||||
}
|
||||
|
||||
/// The message shown beneath the title.
|
||||
var message: String? {
|
||||
guard case let .confirmation(homeserverAddress) = mode else { return nil }
|
||||
|
||||
return switch authenticationFlow {
|
||||
case .login:
|
||||
if homeserverAddress == "matrix.org" {
|
||||
L10n.screenServerConfirmationMessageLoginMatrixDotOrg
|
||||
} else if homeserverAddress == "element.io" {
|
||||
L10n.screenServerConfirmationMessageLoginElementDotIo
|
||||
} else {
|
||||
""
|
||||
}
|
||||
case .register:
|
||||
L10n.screenServerConfirmationMessageRegister
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerConfirmationScreenBindings {
|
||||
/// The chosen server when in `.picker` mode, otherwise `nil`.
|
||||
var pickerSelection: String?
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<ServerConfirmationScreenAlert>?
|
||||
}
|
||||
|
||||
enum ServerConfirmationScreenViewAction {
|
||||
/// Updates the window used as the OAuth presentation anchor.
|
||||
case updateWindow(UIWindow)
|
||||
/// The user would like to continue with the current homeserver.
|
||||
case confirm
|
||||
/// The user would like to change to a different homeserver.
|
||||
case changeServer
|
||||
}
|
||||
|
||||
enum ServerConfirmationScreenAlert: Hashable {
|
||||
/// An alert that informs the user that a server could not be found.
|
||||
case homeserverNotFound
|
||||
/// An alert that informs the user about a bad well-known file.
|
||||
case invalidWellKnown(String)
|
||||
/// An alert that allows the user to learn about sliding sync.
|
||||
case slidingSync
|
||||
/// An alert that informs the user that login isn't supported.
|
||||
case login
|
||||
/// An alert that informs the user that registration isn't supported.
|
||||
case registration
|
||||
/// An alert that informs the user that Element Pro should be used for a particular server.
|
||||
case elementProRequired(serverName: String)
|
||||
/// An unknown error has occurred.
|
||||
case unknownError
|
||||
}
|
||||
-205
@@ -1,205 +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 ServerConfirmationScreenViewModelType = StateStoreViewModelV2<ServerConfirmationScreenViewState, ServerConfirmationScreenViewAction>
|
||||
|
||||
class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol {
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
let authenticationFlow: AuthenticationFlow
|
||||
let appSettings: AppSettings
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private var actionsSubject: PassthroughSubject<ServerConfirmationScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
mode: ServerConfirmationScreenMode,
|
||||
authenticationFlow: AuthenticationFlow,
|
||||
appSettings: AppSettings,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.authenticationService = authenticationService
|
||||
self.authenticationFlow = authenticationFlow
|
||||
self.appSettings = appSettings
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
let pickerSelection: String? = switch mode {
|
||||
case .picker(let providers): providers[0]
|
||||
case .confirmation: nil
|
||||
}
|
||||
|
||||
super.init(initialViewState: ServerConfirmationScreenViewState(mode: mode,
|
||||
authenticationFlow: authenticationFlow,
|
||||
bindings: .init(pickerSelection: pickerSelection)))
|
||||
|
||||
if case .confirmation = mode {
|
||||
authenticationService.homeserver
|
||||
.receive(on: DispatchQueue.main)
|
||||
.map { .confirmation($0.address) }
|
||||
.weakAssign(to: \.state.mode, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
override func process(viewAction: ServerConfirmationScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .updateWindow(let window):
|
||||
guard state.window != window else { return }
|
||||
Task { state.window = window }
|
||||
case .confirm:
|
||||
switch state.mode {
|
||||
case .confirmation:
|
||||
startLoading()
|
||||
Task { await confirmServer() }
|
||||
case .picker:
|
||||
startLoading()
|
||||
Task { await pickServer() }
|
||||
}
|
||||
case .changeServer:
|
||||
actionsSubject.send(.changeServer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func confirmServer() async {
|
||||
defer { stopLoading() }
|
||||
startLoading()
|
||||
|
||||
let homeserver = authenticationService.homeserver.value
|
||||
|
||||
// If the login mode is unknown, the service hasn't been configured and we need to do it now.
|
||||
// Otherwise we can continue the flow as server selection has been performed and succeeded.
|
||||
guard homeserver.loginMode == .unknown || authenticationService.flow != authenticationFlow else {
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
return
|
||||
}
|
||||
|
||||
switch await authenticationService.configure(for: homeserver.address, flow: authenticationFlow) {
|
||||
case .success:
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case .invalidServer, .invalidHomeserverAddress:
|
||||
displayError(.homeserverNotFound)
|
||||
case .invalidWellKnown(let error):
|
||||
displayError(.invalidWellKnown(error))
|
||||
case .slidingSyncNotAvailable:
|
||||
displayError(.slidingSync)
|
||||
case .loginNotSupported:
|
||||
displayError(.login)
|
||||
case .registrationNotSupported:
|
||||
displayError(.registration)
|
||||
case .elementProRequired(let serverName):
|
||||
displayError(.elementProRequired(serverName: serverName))
|
||||
default:
|
||||
displayError(.unknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pickServer() async {
|
||||
defer { stopLoading() }
|
||||
startLoading()
|
||||
|
||||
guard let accountProvider = state.bindings.pickerSelection else {
|
||||
fatalError("It shouldn't be possible to confirm without a selection.")
|
||||
}
|
||||
|
||||
// Don't bother reconfiguring the service if it has already been done for the selected server.
|
||||
let homeserver = authenticationService.homeserver.value
|
||||
guard homeserver.loginMode == .unknown || homeserver.address != accountProvider else {
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
return
|
||||
}
|
||||
|
||||
switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) {
|
||||
case .success:
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
case .failure:
|
||||
// When the servers are hard-coded they should have a valid configuration, so show a generic error.
|
||||
displayError(.unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchLoginURLIfNeededAndContinue() async {
|
||||
guard authenticationService.homeserver.value.loginMode.supportsOAuthFlow else {
|
||||
actionsSubject.send(.continueWithPassword)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
displayError(.unknownError)
|
||||
return
|
||||
}
|
||||
|
||||
startLoading() // Uses the same ID, so no need to worry if the indicator already exists
|
||||
defer { stopLoading() }
|
||||
|
||||
switch await authenticationService.urlForOAuthLogin(loginHint: nil) {
|
||||
case .success(let oAuthData):
|
||||
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
|
||||
case .failure:
|
||||
displayError(.unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
private let loadingIndicatorID = "\(ServerConfirmationScreenViewModel.self)-Loading"
|
||||
|
||||
private func startLoading() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
|
||||
type: .modal,
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func stopLoading() {
|
||||
userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
|
||||
}
|
||||
|
||||
private func displayError(_ type: ServerConfirmationScreenAlert) {
|
||||
switch type {
|
||||
case .homeserverNotFound:
|
||||
state.bindings.alertInfo = AlertInfo(id: .homeserverNotFound,
|
||||
title: L10n.errorUnknown,
|
||||
message: L10n.screenChangeServerErrorInvalidHomeserver)
|
||||
case .invalidWellKnown(let error):
|
||||
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnown(error),
|
||||
title: L10n.commonServerNotSupported,
|
||||
message: L10n.screenChangeServerErrorInvalidWellKnown(error))
|
||||
case .slidingSync:
|
||||
let nonBreakingAppName = InfoPlistReader.main.bundleDisplayName.replacingOccurrences(of: " ", with: "\u{00A0}")
|
||||
state.bindings.alertInfo = AlertInfo(id: .slidingSync,
|
||||
title: L10n.commonServerNotSupported,
|
||||
message: L10n.screenChangeServerErrorNoSlidingSyncMessage(nonBreakingAppName))
|
||||
case .login:
|
||||
state.bindings.alertInfo = AlertInfo(id: .login,
|
||||
title: L10n.commonServerNotSupported,
|
||||
message: L10n.screenLoginErrorUnsupportedAuthentication)
|
||||
case .registration:
|
||||
state.bindings.alertInfo = AlertInfo(id: .registration,
|
||||
title: L10n.commonServerNotSupported,
|
||||
message: L10n.errorAccountCreationNotPossible)
|
||||
case .elementProRequired(let serverName):
|
||||
state.bindings.alertInfo = AlertInfo(id: .elementProRequired(serverName: serverName),
|
||||
title: L10n.screenChangeServerErrorElementProRequiredTitle,
|
||||
message: L10n.screenChangeServerErrorElementProRequiredMessage(serverName),
|
||||
primaryButton: .init(title: L10n.screenChangeServerErrorElementProRequiredActionIos) {
|
||||
UIApplication.shared.open(self.appSettings.elementProAppStoreURL)
|
||||
},
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
case .unknownError:
|
||||
state.bindings.alertInfo = AlertInfo(id: .unknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +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 ServerConfirmationScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> { get }
|
||||
var context: ServerConfirmationScreenViewModelType.Context { get }
|
||||
}
|
||||
-169
@@ -1,169 +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 ServerConfirmationScreen: View {
|
||||
@Bindable var context: ServerConfirmationScreenViewModel.Context
|
||||
|
||||
private var backgroundColor: Color {
|
||||
switch context.viewState.mode {
|
||||
case .confirmation: .compound.bgCanvasDefault
|
||||
case .picker: .compound.bgSubtleSecondaryLevel0
|
||||
}
|
||||
}
|
||||
|
||||
private var headerIcon: KeyPath<CompoundIcons, Image> {
|
||||
switch context.viewState.mode {
|
||||
case .confirmation: \.userProfileSolid
|
||||
case .picker: \.homeSolid
|
||||
}
|
||||
}
|
||||
|
||||
private var headerIconStyle: BigIcon.Style {
|
||||
switch context.viewState.mode {
|
||||
case .confirmation: .defaultSolid
|
||||
case .picker: .default
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar) {
|
||||
VStack(spacing: 36) {
|
||||
header
|
||||
mainContent
|
||||
}
|
||||
} bottomContent: {
|
||||
buttons
|
||||
}
|
||||
.background()
|
||||
.backgroundStyle(backgroundColor)
|
||||
.alert(item: $context.alertInfo)
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
}
|
||||
|
||||
/// The main content of the view to be shown in a scroll view.
|
||||
var header: some View {
|
||||
VStack(spacing: 8) {
|
||||
BigIcon(icon: headerIcon, style: headerIconStyle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(context.viewState.title)
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let message = context.viewState.message {
|
||||
Text(message)
|
||||
.font(.compound.bodyMD)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var mainContent: some View {
|
||||
if case .picker(let accountProviders) = context.viewState.mode {
|
||||
FakeInlinePicker(items: accountProviders,
|
||||
icon: \.host,
|
||||
selection: $context.pickerSelection)
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker)
|
||||
}
|
||||
}
|
||||
|
||||
/// The action buttons shown at the bottom of the view.
|
||||
var buttons: some View {
|
||||
VStack(spacing: 16) {
|
||||
Button { context.send(viewAction: .confirm) } label: {
|
||||
Text(L10n.actionContinue)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.continue)
|
||||
|
||||
if case .confirmation = context.viewState.mode {
|
||||
Button { context.send(viewAction: .changeServer) } label: {
|
||||
Text(L10n.screenServerConfirmationChangeServer)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.padding(14)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.changeServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is such a hack. I hate it!
|
||||
/// But… We're not in a List/Form, the compound picker doesn't
|
||||
/// support icons and this screen's design might change so 🤷♂️.
|
||||
private struct FakeInlinePicker: View {
|
||||
let items: [String]
|
||||
let icon: KeyPath<CompoundIcons, Image>
|
||||
@Binding var selection: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
ListRow(label: .default(title: item, icon: icon),
|
||||
kind: .selection(isSelected: selection == item) {
|
||||
selection = item
|
||||
})
|
||||
.overlay(alignment: .bottom) {
|
||||
if item != items.last {
|
||||
Divider()
|
||||
.hidden()
|
||||
.overlay(Color.compound._borderInteractiveSecondaryAlpha)
|
||||
.padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let loginViewModel = makeViewModel(mode: .confirmation("matrix.org"), flow: .login)
|
||||
static let registerViewModel = makeViewModel(mode: .confirmation("matrix.org"), flow: .register)
|
||||
static let pickerViewModel = makeViewModel(mode: .picker(["dept1.company.com", "dept2.company.com", "dept3.company.com"]), flow: .login)
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
ServerConfirmationScreen(context: loginViewModel.context)
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
}
|
||||
.previewDisplayName("Login")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerConfirmationScreen(context: registerViewModel.context)
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
}
|
||||
.previewDisplayName("Register")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerConfirmationScreen(context: pickerViewModel.context)
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
}
|
||||
.previewDisplayName("Picker")
|
||||
}
|
||||
|
||||
static func makeViewModel(mode: ServerConfirmationScreenMode, flow: AuthenticationFlow) -> ServerConfirmationScreenViewModel {
|
||||
ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock,
|
||||
mode: mode,
|
||||
authenticationFlow: flow,
|
||||
appSettings: .volatile(),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -14,12 +14,13 @@ struct ServerSelectionScreenCoordinatorParameters {
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
let authenticationFlow: AuthenticationFlow
|
||||
let appSettings: AppSettings
|
||||
let homeserverHistoryManager: HomeserverHistoryManager
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum ServerSelectionScreenCoordinatorAction {
|
||||
case updated
|
||||
case dismiss
|
||||
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
|
||||
case continueWithPassword
|
||||
}
|
||||
|
||||
/// Note: This code was brought over from Riot, we should move the authentication service logic into the view model.
|
||||
@@ -36,9 +37,18 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: ServerSelectionScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let mode: ServerSelectionScreenMode = if parameters.appSettings.allowOtherAccountProviders {
|
||||
.userInput
|
||||
} else {
|
||||
.picker(parameters.appSettings.accountProviders)
|
||||
}
|
||||
|
||||
viewModel = ServerSelectionScreenViewModel(authenticationService: parameters.authenticationService,
|
||||
mode: mode,
|
||||
authenticationFlow: parameters.authenticationFlow,
|
||||
appSettings: parameters.appSettings,
|
||||
homeserverHistoryManager: parameters.homeserverHistoryManager,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
@@ -50,10 +60,10 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .updated:
|
||||
actionsSubject.send(.updated)
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .continueWithOAuth(let oAuthData, let window):
|
||||
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
|
||||
case .continueWithPassword:
|
||||
actionsSubject.send(.continueWithPassword)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
+73
-11
@@ -6,21 +6,71 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum ServerSelectionScreenViewModelAction {
|
||||
/// The homeserver selection has been updated.
|
||||
case updated
|
||||
/// Dismiss the view without using the entered address.
|
||||
case dismiss
|
||||
/// Continue the flow using the provided OAuth parameters.
|
||||
case continueWithOAuth(data: OAuthAuthorizationDataProxy, window: UIWindow)
|
||||
/// Continue the flow using password authentication.
|
||||
case continueWithPassword
|
||||
}
|
||||
|
||||
enum ServerSelectionScreenMode: Equatable {
|
||||
/// The user is confirming the displayed account provider (or can enter their own).
|
||||
case userInput
|
||||
/// The user is only allowed to pick from a list of account providers.
|
||||
case picker([String])
|
||||
}
|
||||
|
||||
struct ServerSelectionScreenViewState: BindableState {
|
||||
/// The message to be shown in the text field footer when no error has occurred.
|
||||
private let regularFooterMessage = L10n.screenChangeServerFormNotice
|
||||
/// Whether the screen is configured to confirm a single account provider or pick one from a list of many.
|
||||
var mode: ServerSelectionScreenMode
|
||||
/// Whether the screen is configured for registration or existing account login
|
||||
var authenticationFlow: AuthenticationFlow
|
||||
/// The presentation anchor used for OAuth authentication.
|
||||
var window: UIWindow?
|
||||
|
||||
/// View state that can be bound to from SwiftUI.
|
||||
var bindings: ServerSelectionScreenBindings
|
||||
|
||||
/// Upon introspection, this is the UITextField backing the TextField for server input
|
||||
var textField: UITextField? {
|
||||
get { textFieldAdapter.textField }
|
||||
set { textFieldAdapter.textField = newValue }
|
||||
}
|
||||
|
||||
/// Adapts the text field for custom functionality beyond what's available in SwiftUI
|
||||
let textFieldAdapter = TextFieldAdapter()
|
||||
|
||||
var screenTitle: String {
|
||||
switch authenticationFlow {
|
||||
case .login:
|
||||
L10n.screenChangeServerNavigationTitleLogin
|
||||
case .register:
|
||||
L10n.screenChangeServerNavigationTitleRegister
|
||||
}
|
||||
}
|
||||
|
||||
/// The header text for the screen
|
||||
var screenHeader: String {
|
||||
switch authenticationFlow {
|
||||
case .login:
|
||||
L10n.screenChangeServerTitleLogin
|
||||
case .register:
|
||||
L10n.screenChangeServerTitleRegister
|
||||
}
|
||||
}
|
||||
|
||||
/// The message to be shown in the text field footer when no error has occurred.
|
||||
var regularFooterMessage: String {
|
||||
switch authenticationFlow {
|
||||
case .login:
|
||||
L10n.screenChangeServerTextfieldFooterLogin
|
||||
case .register:
|
||||
L10n.screenChangeServerTextfieldFooterRegister
|
||||
}
|
||||
}
|
||||
|
||||
/// An error message to be shown in the text field footer.
|
||||
var footerErrorMessage: String?
|
||||
|
||||
@@ -36,22 +86,32 @@ struct ServerSelectionScreenViewState: BindableState {
|
||||
|
||||
/// Whether it is possible to continue when tapping the confirmation button.
|
||||
var hasValidationError: Bool {
|
||||
bindings.homeserverAddress.isEmpty || isShowingFooterError
|
||||
switch mode {
|
||||
case .userInput:
|
||||
bindings.homeserverAddress.isEmpty || isShowingFooterError
|
||||
case .picker(let options):
|
||||
!options.contains(bindings.homeserverAddress) || isShowingFooterError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerSelectionScreenBindings {
|
||||
/// The homeserver address input by the user.
|
||||
/// The homeserver address input or chosen by the user.
|
||||
var homeserverAddress: String
|
||||
/// The selection range in `homeserverAddress`
|
||||
var homeserverSelection: TextSelection?
|
||||
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<ServerSelectionScreenErrorType>?
|
||||
}
|
||||
|
||||
enum ServerSelectionScreenViewAction {
|
||||
/// Updates the window used as the OAuth presentation anchor.
|
||||
case updateWindow(UIWindow)
|
||||
/// Updates the textfield from introspection
|
||||
case updateTextField(UITextField)
|
||||
/// The user would like to use the homeserver at the input address.
|
||||
case confirm
|
||||
/// Dismiss the view without using the entered address.
|
||||
case dismiss
|
||||
/// Clear any errors shown in the text field footer.
|
||||
case clearFooterError
|
||||
}
|
||||
@@ -67,4 +127,6 @@ enum ServerSelectionScreenErrorType: Hashable {
|
||||
case registrationAlert
|
||||
/// An alert that informs the user that Element Pro should be used for a particular server.
|
||||
case elementProAlert
|
||||
/// An unknown error has occurred.
|
||||
case unknownError
|
||||
}
|
||||
|
||||
+115
-17
@@ -17,31 +17,60 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
private let appSettings: AppSettings
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
/// Debounces autocompletion from user input
|
||||
private var debouncedAutocompleteTask: Task<Void, Never>?
|
||||
|
||||
private var actionsSubject: PassthroughSubject<ServerSelectionScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<ServerSelectionScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let homeserverHistoryManager: HomeserverHistoryManager
|
||||
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
mode: ServerSelectionScreenMode,
|
||||
authenticationFlow: AuthenticationFlow,
|
||||
appSettings: AppSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.authenticationService = authenticationService
|
||||
self.authenticationFlow = authenticationFlow
|
||||
self.appSettings = appSettings
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.homeserverHistoryManager = homeserverHistoryManager
|
||||
|
||||
let bindings = ServerSelectionScreenBindings(homeserverAddress: authenticationService.homeserver.value.address)
|
||||
super.init(initialViewState: ServerSelectionScreenViewState(bindings: bindings))
|
||||
let homeserverAddress: String
|
||||
if case .picker(let providers) = mode {
|
||||
homeserverAddress = providers[0]
|
||||
} else {
|
||||
homeserverAddress = authenticationService.homeserver.value.address
|
||||
}
|
||||
let bindings = ServerSelectionScreenBindings(homeserverAddress: homeserverAddress)
|
||||
super.init(initialViewState: ServerSelectionScreenViewState(mode: mode, authenticationFlow: authenticationFlow, bindings: bindings))
|
||||
|
||||
context.viewState.textFieldAdapter.keystrokePublisher
|
||||
.sink { [weak self] in
|
||||
self?.didUpdateTextFromKeystroke(userProvidedString: $0)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func process(viewAction: ServerSelectionScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .updateWindow(let window):
|
||||
guard state.window != window else { return } // stop the infinite loop
|
||||
state.window = window
|
||||
case .updateTextField(let textField):
|
||||
guard state.textField != textField else { return } // stop the infinite loop
|
||||
state.textField = textField
|
||||
case .confirm:
|
||||
configureHomeserver()
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
switch state.mode {
|
||||
case .userInput:
|
||||
Task { await configureHomeserver() }
|
||||
case .picker:
|
||||
Task { await pickServer() }
|
||||
}
|
||||
case .clearFooterError:
|
||||
clearFooterError()
|
||||
}
|
||||
@@ -49,22 +78,62 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func pickServer() async {
|
||||
let accountProvider = state.bindings.homeserverAddress
|
||||
guard accountProvider.isEmpty == false else {
|
||||
fatalError("It shouldn't be possible to confirm without a selection.")
|
||||
}
|
||||
|
||||
startLoading()
|
||||
|
||||
switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected server: \(accountProvider)")
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
stopLoading()
|
||||
case .failure:
|
||||
MXLog.info("Invalid server: \(accountProvider)")
|
||||
stopLoading()
|
||||
// When the servers are hard-coded they should have a valid configuration, so show a generic error.
|
||||
state.bindings.alertInfo = AlertInfo(id: .unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
|
||||
private func configureHomeserver() {
|
||||
private func configureHomeserver() async {
|
||||
let homeserverAddress = state.bindings.homeserverAddress
|
||||
startLoading()
|
||||
|
||||
Task {
|
||||
switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected homeserver: \(homeserverAddress)")
|
||||
actionsSubject.send(.updated)
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
MXLog.info("Invalid homeserver: \(homeserverAddress)")
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected homeserver: \(homeserverAddress)")
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
MXLog.info("Invalid homeserver: \(homeserverAddress)")
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchLoginURLIfNeededAndContinue() async {
|
||||
guard authenticationService.homeserver.value.loginMode.supportsOAuthFlow else {
|
||||
actionsSubject.send(.continueWithPassword)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
MXLog.error("No window available for OAuth presentation")
|
||||
showFooterMessage(L10n.errorUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
switch await authenticationService.urlForOAuthLogin(loginHint: nil) {
|
||||
case .success(let oAuthData):
|
||||
actionsSubject.send(.continueWithOAuth(data: oAuthData, window: window))
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed to get OAuth login URL: \(error)")
|
||||
showFooterMessage(L10n.errorUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +194,33 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
guard state.footerErrorMessage != nil else { return }
|
||||
withElementAnimation { state.footerErrorMessage = nil }
|
||||
}
|
||||
|
||||
/// Given the textfield's complete text from the user's latest keystroke, evaluate if it matches any historical
|
||||
/// server prefixes. If there's a match, update the text value with the complete server, highlighting the portion
|
||||
/// that was appended.
|
||||
private func didUpdateTextFromKeystroke(userProvidedString: String) {
|
||||
let new = userProvidedString
|
||||
let lowerNew = new.lowercased()
|
||||
|
||||
guard
|
||||
lowerNew.isEmpty == false,
|
||||
let expectedMatch = homeserverHistoryManager.server(matchingPrefix: lowerNew),
|
||||
lowerNew != expectedMatch
|
||||
else { return }
|
||||
|
||||
let appendage = expectedMatch.dropFirst(new.count)
|
||||
let newInput = "\(new)\(appendage)"
|
||||
|
||||
let selectionRange = newInput.index(newInput.startIndex, offsetBy: new.count)..<newInput.endIndex
|
||||
|
||||
// debounce a delay to assure that we are not in the same swiftui update cycle
|
||||
debouncedAutocompleteTask?.cancel()
|
||||
debouncedAutocompleteTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
guard Task.isCancelled == false else { return }
|
||||
context.homeserverAddress = newInput
|
||||
context.viewState.textField?.text = newInput
|
||||
context.homeserverSelection = TextSelection(range: selectionRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-43
@@ -12,74 +12,91 @@ import SwiftUI
|
||||
struct ServerSelectionScreen: View {
|
||||
@Bindable var context: ServerSelectionScreenViewModel.Context
|
||||
|
||||
private var backgroundColor: Color {
|
||||
switch context.viewState.mode {
|
||||
case .userInput: .compound.bgCanvasDefault
|
||||
case .picker: .compound.bgSubtleSecondaryLevel0
|
||||
}
|
||||
}
|
||||
|
||||
private var headerIconStyle: BigIcon.Style {
|
||||
switch context.viewState.mode {
|
||||
case .userInput: .defaultSolid
|
||||
case .picker: .default
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
FullscreenDialog(topPadding: 24) {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
|
||||
.padding(.bottom, 36)
|
||||
|
||||
serverForm
|
||||
}
|
||||
.readableFrame()
|
||||
.padding(.horizontal, 16)
|
||||
} bottomContent: {
|
||||
continueButton
|
||||
}
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.toolbar { toolbar }
|
||||
.background()
|
||||
.backgroundStyle(backgroundColor)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(context.viewState.screenTitle)
|
||||
.alert(item: $context.alertInfo)
|
||||
.interactiveDismissDisabled()
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, message and icon at the top of the screen.
|
||||
var header: some View {
|
||||
VStack(spacing: 8) {
|
||||
BigIcon(icon: \.host)
|
||||
BigIcon(icon: \.userProfileSolid, style: headerIconStyle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(L10n.screenChangeServerTitle)
|
||||
Text(context.viewState.screenHeader)
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
|
||||
Text(L10n.screenChangeServerSubtitle)
|
||||
.font(.compound.bodyMD)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
/// The text field and confirm button where the user enters a server URL.
|
||||
/// The main input and confirm button.
|
||||
var serverForm: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
TextField(L10n.commonServerUrl, text: $context.homeserverAddress)
|
||||
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerFormHeader),
|
||||
footerText: Text(context.viewState.footerMessage),
|
||||
state: context.viewState.isShowingFooterError ? .error : .default,
|
||||
accessibilityIdentifier: A11yIdentifiers.changeServerScreen.server))
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
|
||||
Button(action: submit) {
|
||||
Text(L10n.actionContinue)
|
||||
switch context.viewState.mode {
|
||||
case .userInput:
|
||||
TextField(L10n.screenChangeServerTextfieldPlaceholder, text: $context.homeserverAddress, selection: $context.homeserverSelection)
|
||||
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerTextfieldHeader),
|
||||
footerText: Text(context.viewState.footerMessage),
|
||||
state: context.viewState.isShowingFooterError ? .error : .default,
|
||||
accessibilityIdentifier: A11yIdentifiers.changeServerScreen.server))
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.introspect(.textField, on: .supportedVersions) {
|
||||
context.send(viewAction: .updateTextField($0))
|
||||
}
|
||||
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
case .picker(let providers):
|
||||
FakeInlinePicker(items: providers,
|
||||
icon: \.host,
|
||||
selection: $context.homeserverAddress)
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.disabled(context.viewState.hasValidationError)
|
||||
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.continue)
|
||||
}
|
||||
}
|
||||
|
||||
var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button { context.send(viewAction: .dismiss) } label: {
|
||||
Text(L10n.actionCancel)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
|
||||
private var continueButton: some View {
|
||||
Button(action: submit) {
|
||||
Text(L10n.actionContinue)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.disabled(context.viewState.hasValidationError)
|
||||
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.continue)
|
||||
}
|
||||
|
||||
/// Sends the `confirm` view action so long as the text field input is valid.
|
||||
@@ -89,38 +106,81 @@ struct ServerSelectionScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private struct FakeInlinePicker: View {
|
||||
let items: [String]
|
||||
let icon: KeyPath<CompoundIcons, Image>
|
||||
@Binding var selection: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
ListRow(label: .default(title: item, icon: icon),
|
||||
kind: .selection(isSelected: selection == item) {
|
||||
selection = item
|
||||
})
|
||||
.accessibilityLabel(item)
|
||||
.accessibilityIdentifier(item)
|
||||
.overlay(alignment: .bottom) {
|
||||
if item != items.last {
|
||||
Divider()
|
||||
.hidden()
|
||||
.overlay(Color.compound._borderInteractiveSecondaryAlpha)
|
||||
.padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
struct ServerSelection_Previews: PreviewProvider, TestablePreview {
|
||||
static let matrixViewModel = makeViewModel(for: "https://matrix.org")
|
||||
static let emptyViewModel = makeViewModel(for: "")
|
||||
static let invalidViewModel = makeViewModel(for: "thisisbad")
|
||||
static let matrixViewModel = makeViewModel(mode: .userInput, homeserverAddress: "matrix.org")
|
||||
static let emptyViewModel = makeViewModel(mode: .userInput, homeserverAddress: "")
|
||||
static let invalidViewModel = makeViewModel(mode: .userInput, homeserverAddress: "thisisbad")
|
||||
static let pickerViewModel = makeViewModel(mode: .picker(["matrix.org", "foo.bar", "baz.me"]), homeserverAddress: "foo.bar")
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: matrixViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Matrix.org")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: emptyViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Empty")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: invalidViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: invalidViewModel.context.observe(\.viewState.hasValidationError))
|
||||
.previewDisplayName("Error")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: pickerViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Picker")
|
||||
}
|
||||
|
||||
static func makeViewModel(for homeserverAddress: String) -> ServerSelectionScreenViewModel {
|
||||
static func makeViewModel(mode: ServerSelectionScreenMode = .userInput, homeserverAddress: String) -> ServerSelectionScreenViewModel {
|
||||
let authenticationService = AuthenticationService.mock
|
||||
|
||||
let appSettings = AppSettings.volatile()
|
||||
|
||||
let viewModel = ServerSelectionScreenViewModel(authenticationService: authenticationService,
|
||||
mode: mode,
|
||||
authenticationFlow: .login,
|
||||
appSettings: .volatile(),
|
||||
appSettings: appSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
viewModel.context.homeserverAddress = homeserverAddress
|
||||
if homeserverAddress == "thisisbad" {
|
||||
if case .userInput = mode, homeserverAddress == "thisisbad" {
|
||||
viewModel.context.send(viewAction: .confirm)
|
||||
}
|
||||
return viewModel
|
||||
|
||||
@@ -64,7 +64,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
}
|
||||
|
||||
// When updating these, don't forget to update the reset method too.
|
||||
homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
|
||||
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultServer, loginMode: .unknown))
|
||||
flow = .login
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
}
|
||||
|
||||
func reset() {
|
||||
homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
|
||||
homeserverSubject.send(LoginHomeserver(address: appSettings.defaultServer, loginMode: .unknown))
|
||||
flow = .login
|
||||
client = nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Manages the tracking, removal, and autocomplete matchin of previous servers in AppSettings.
|
||||
/// Servers are sorted by most recently used.
|
||||
class HomeserverHistoryManager {
|
||||
let appSettings: AppSettings
|
||||
|
||||
/// cache with confirmed lowercase servers to avoid having to lowercase the entire list on every keystroke
|
||||
private var cachedServers: [String] = []
|
||||
var servers: [String] {
|
||||
cachedServers
|
||||
}
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(appSettings: AppSettings) {
|
||||
self.appSettings = appSettings
|
||||
|
||||
appSettings
|
||||
.previousServersPublisher
|
||||
.sink { [weak self] _ in
|
||||
self?.updateCachedServers()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Tracks the given server in AppSettings's `previousServers`. The server is asserted
|
||||
/// unique and placed at the beginning of the list, such that the most recent connected server is at the front.
|
||||
func addServerToList(_ server: String) {
|
||||
MXLog.info("Tracking server in previous servers: \(server)")
|
||||
|
||||
let lowercaseServer = server.lowercased()
|
||||
var newList = _removeServerFromList(lowercaseServer) // don't trigger a publisher update
|
||||
newList.insert(lowercaseServer, at: 0)
|
||||
appSettings.previousServers = newList
|
||||
}
|
||||
|
||||
/// Removes `server` from the tracking history of `AppSettings.previousServers`
|
||||
func removeServerFromList(_ server: String) {
|
||||
let lowercaseServer = server.lowercased()
|
||||
appSettings.previousServers = _removeServerFromList(lowercaseServer)
|
||||
}
|
||||
|
||||
/// Removes `lowercaseServer` from `appSettings.previousServers`
|
||||
/// - Parameter lowercaseServer: the server string. It MUST be passed in with all lowercased letters.
|
||||
/// - Returns: a new copy of the list without `lowercaseServer`
|
||||
private func _removeServerFromList(_ lowercaseServer: String) -> [String] {
|
||||
var newList = appSettings.previousServers
|
||||
newList.removeAll { $0.lowercased() == lowercaseServer }
|
||||
return newList
|
||||
}
|
||||
|
||||
/// Retrieves the most recent server matching the given prefix, if any
|
||||
func server(matchingPrefix prefix: String) -> String? {
|
||||
let lowercasedPrefix = prefix.lowercased()
|
||||
return servers.first { $0.hasPrefix(lowercasedPrefix) }
|
||||
}
|
||||
|
||||
/// Retrieves all servers from AppSettings, previous and pre-provided and stores them in memory
|
||||
/// in all lowercase. This is the list that matches are made against.
|
||||
private func updateCachedServers() {
|
||||
let previous = appSettings.previousServers.map { $0.lowercased() }
|
||||
let defaultProviders = appSettings.accountProviders.map { $0.lowercased() }
|
||||
|
||||
cachedServers = previous + defaultProviders
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,7 @@ class MockScreen: Identifiable {
|
||||
let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: AuthenticationService.mock,
|
||||
authenticationFlow: .login,
|
||||
appSettings: appSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
|
||||
userIndicatorController: userIndicatorController))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
|
||||
@@ -24,22 +24,10 @@ extension XCUIApplication {
|
||||
getStartedButton.tap(.center)
|
||||
|
||||
if let homeserver {
|
||||
let changeHomeserverButton = buttons[A11yIdentifiers.serverConfirmationScreen.changeServer]
|
||||
XCTAssertTrue(changeHomeserverButton.waitForExistence(timeout: 10.0))
|
||||
changeHomeserverButton.tap(.center)
|
||||
|
||||
// Server selection screen appears directly after sign in
|
||||
let homeserverTextField = textFields[A11yIdentifiers.changeServerScreen.server]
|
||||
XCTAssertTrue(homeserverTextField.waitForExistence(timeout: 10.0))
|
||||
|
||||
homeserverTextField.clearAndTypeText(homeserver, app: self)
|
||||
|
||||
let confirmButton = buttons[A11yIdentifiers.changeServerScreen.continue]
|
||||
XCTAssertTrue(confirmButton.waitForExistence(timeout: 10.0))
|
||||
confirmButton.tap(.center)
|
||||
|
||||
// Wait for server confirmation to finish
|
||||
currentTestCase.expectation(for: doesNotExistPredicate, evaluatedWith: confirmButton)
|
||||
currentTestCase.waitForExpectations(timeout: 300.0)
|
||||
}
|
||||
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
@@ -50,7 +38,7 @@ extension XCUIApplication {
|
||||
// Keep looping on the Continue button for ~5 minutes until the Authentication Session is happy.
|
||||
var remainingAttempts = 10
|
||||
while !webAuthenticationSessionAlertContinueButton.exists {
|
||||
let continueButton = buttons[A11yIdentifiers.serverConfirmationScreen.continue]
|
||||
let continueButton = buttons[A11yIdentifiers.changeServerScreen.continue]
|
||||
XCTAssertTrue(continueButton.waitForExistence(timeout: 30.0))
|
||||
continueButton.tap(.center)
|
||||
|
||||
|
||||
@@ -1152,13 +1152,6 @@ extension PreviewTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func serverConfirmationScreen() async throws {
|
||||
for (index, preview) in ServerConfirmationScreen_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func serverSelection() async throws {
|
||||
for (index, preview) in ServerSelection_Previews._allPreviews.enumerated() {
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f27d379cfab2f1a6051bafb477659476a7cc6b49741aa6324b9a398899743fe
|
||||
size 102448
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6b81a350c87e4507538d1164e4abee2b20744cd4d39a1fbe2e11670b964c45c5
|
||||
size 119863
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d13b5587f99e70f5af6ffa67dfed0ac1e86463a3ef5945bffe054882741b81ac
|
||||
size 62912
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fe55253bdbb21c766fd15417e3f8ab7eeac82f3e6454fec96256eca4c936f611
|
||||
size 86722
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a8f9b19e48979489ecd1f85b0d4e269a6c71d1b0914b6bef4edcc5b80e7196b
|
||||
size 112550
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:13215b8b562300a91e14cb299a90632f0755a041e7876a0d332ffc1c32459306
|
||||
size 114426
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f42c0c369f36a86708c5b95cf26aa99c8400cb1810bfe6077395dd8f2d17f0ef
|
||||
size 60925
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d42afd834e3832731c803923520cc33b869946f38990c57dbf70064ed0b4d691
|
||||
size 70052
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:728f62529a6525f905d3c595b2dc9b5c7d9298decb75c627c404d7acf421718b
|
||||
size 108154
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:00d7ab6cdc91aa264756e850f9ed2eecd54bf21b0bbf91faea6a701b86b020c4
|
||||
size 133098
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a59fd2dafc033210c6e8e723ab37099b8f7e7f12382d1a0fb2d7c70a4947207
|
||||
size 67977
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7911437071e4ec2b83d685a4e89050bc9853bc82944bfaac6da9df662d05d527
|
||||
size 97511
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96101e1599fe18d76c4a01fc80134541b81e0c566d064ba8833199aafb4b91b7
|
||||
size 110835
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:573dbe543207bceaa0664098968231b0d47d5a975f0f0de08af52616ffc4dffb
|
||||
size 132562
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:56be3f7c9c69e523899f47cbab30d2b3a30d666e3caa191c51ac9ddf113a4492
|
||||
size 66474
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75b1ca6e6043aad97320515df2e94b1798a0fc8be008115468e01f1ca279988e
|
||||
size 93280
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6150071c11fd4a59df9f29cf69b7fc19128821ee21818bf41dc5ed01f6eeaa95
|
||||
size 118572
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f719fd2b7950d0f3c8449e4ad9a3628313665dae2655da5094fbf336132a6711
|
||||
size 150213
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:81bda74f93462654453886ce150c6e8b16c0becd95f1aabd1e10d46b207ec7c1
|
||||
size 73302
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8d7f67046e3d6726fae663582ab78c735bd3fa66aad976659a0dac44c6cbb33b
|
||||
size 105978
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da8278c98a491826073bc53650cc627eace25252ba589f05feed4d87dcd9c380
|
||||
size 111223
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:72a13132ee2a46dafaab55ee15e85478874be00528ebe2810797437f9e07f8cb
|
||||
size 132790
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e90e609769486d289c995b3e957f67b23debc49ca6ebe0becdbd7b4dce736d56
|
||||
size 66735
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:57fe8ab5d77b2299024aa4dd32dd85f0d40222ada59832435ca663675109989c
|
||||
size 92903
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc14af1c2cf7495862ce1d7ecba2ec065c193c61af02b8364a1680441541f13e
|
||||
size 108637
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3fbcf065f46808bf8baf1d3e1eda5c20cc40d4382c58538b5f139568ab32ffa3
|
||||
size 118958
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09f9b83ac8a74893bb1a7791dbb47e013c2f0e61e8d467d1dfa76f7ffc162998
|
||||
size 56838
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:adad286a213181b1f8e8f1a4de6a33a28f6941806d26ae60026c18d09b7d45a0
|
||||
size 64974
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c58d5a2289a4c62ae7910940f5fd10a131ba0673e9b396ee698999342d6e619
|
||||
size 105461
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fe4e4c9f5cb203297a7a31d83febb449894eca6b2f3001ce934967c5369e42cc
|
||||
size 102905
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:575c54730b1b9fbe790c27a17ea6e99e44f122c3caef35e499adbf3ee0363ccc
|
||||
size 120730
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6f20771a2bc3b8bf82cad88f7e7f77c300923df6e8fed450cec3036916d5f296
|
||||
size 116158
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:213e1d081a6faf3964dbcf1808a7f0b07b5b3a410fe6cee7be27216bfba2db31
|
||||
size 113696
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4fc30b95317161fa9126d0e593f7def4e15733bede5bbe652b5fee710d463354
|
||||
size 147912
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f7fcbd78804fac70bfd60f105628b3b76ec39a4e393626122ea710217ed7a358
|
||||
size 58588
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c64d5df3d81e8f53a9041b3880979d703ab4b002fbb723e9046cc93492deb8b1
|
||||
size 56263
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:463ea675e4e84186d654a660ee8c39a3ac96fff0ce260e4ef3ce29f5433d244c
|
||||
size 79592
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:03b55270586b00290e10584cb7c02f475e84885f3d66c2b76931b5e2a05fc53f
|
||||
size 72927
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d8eb226e3906ffa0bde9486dcb72ca0967775e0ed018b0eac6d6bf81b503e862
|
||||
size 70674
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:76f660ea1b55fdacae2a6e3858837af29ebdd681d48d6307932a83e163948503
|
||||
size 113311
|
||||
@@ -20,18 +20,10 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
|
||||
// Splash Screen: Tap get started button
|
||||
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
|
||||
|
||||
// Server Confirmation: Tap change server button
|
||||
app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap()
|
||||
|
||||
// Server Selection: Clear the default, enter OAuth server and continue.
|
||||
// Server Selection: Clear the default, enter a server address and submit.
|
||||
// The \n triggers confirm directly, navigating to the login screen.
|
||||
app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("example.com\n", app: app)
|
||||
|
||||
// Await for the button to be hittable, since a loader may appear
|
||||
let serverConfirmationContinueButton = app.buttons[A11yIdentifiers.serverConfirmationScreen.continue]
|
||||
XCTAssertTrue(serverConfirmationContinueButton.wait(for: \.isHittable, toEqual: true, timeout: 2.0))
|
||||
// Server Confirmation: Tap continue button
|
||||
serverConfirmationContinueButton.tap()
|
||||
|
||||
// Login Screen: Wait for continue button to appear
|
||||
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
|
||||
XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0))
|
||||
@@ -53,18 +45,10 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
|
||||
// Splash Screen: Tap get started button
|
||||
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
|
||||
|
||||
// Server Confirmation: Tap change server button
|
||||
app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap()
|
||||
|
||||
// Server Selection: Clear the default, enter OAuth server and continue.
|
||||
// Server Selection: Clear the default, enter a server address and submit.
|
||||
// The \n triggers confirm directly, navigating to the login screen.
|
||||
app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("example.com\n", app: app)
|
||||
|
||||
// Await for the button to be hittable, since a loader may appear
|
||||
let serverConfirmationContinueButton = app.buttons[A11yIdentifiers.serverConfirmationScreen.continue]
|
||||
XCTAssertTrue(serverConfirmationContinueButton.wait(for: \.isHittable, toEqual: true, timeout: 2.0))
|
||||
// Server Confirmation: Tap continue button
|
||||
serverConfirmationContinueButton.tap()
|
||||
|
||||
// Login Screen: Wait for continue button to appear
|
||||
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
|
||||
XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0))
|
||||
@@ -88,18 +72,10 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
|
||||
// Splash Screen: Tap get started button
|
||||
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
|
||||
|
||||
// Server Confirmation: Tap change server button
|
||||
app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap()
|
||||
|
||||
// Server Selection: Clear the default, enter OAuth server and continue.
|
||||
// Server Selection: Clear the default, enter a server address and submit.
|
||||
// The \n triggers confirm directly, navigating to the login screen.
|
||||
app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("example.com\n", app: app)
|
||||
|
||||
// Await for the button to be hittable, since a loader may appear
|
||||
let serverConfirmationContinueButton = app.buttons[A11yIdentifiers.serverConfirmationScreen.continue]
|
||||
XCTAssertTrue(serverConfirmationContinueButton.wait(for: \.isHittable, toEqual: true, timeout: 2.0))
|
||||
// Server Confirmation: Tap continue button
|
||||
serverConfirmationContinueButton.tap()
|
||||
|
||||
// Login Screen: Wait for continue button to appear
|
||||
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
|
||||
XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0))
|
||||
@@ -125,9 +101,6 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
|
||||
// Splash Screen: Tap get started button
|
||||
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
|
||||
|
||||
// Server Confirmation: Tap change server button
|
||||
app.buttons[A11yIdentifiers.serverConfirmationScreen.changeServer].tap()
|
||||
|
||||
// Server Selection: Clear the default, enter OAuth server and continue.
|
||||
app.textFields[A11yIdentifiers.changeServerScreen.server].clearAndTypeText("company.com\n", app: app)
|
||||
|
||||
@@ -139,8 +112,8 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
|
||||
// Keep looping on the Continue button for ~5 minutes until the Authentication Session is happy.
|
||||
var remainingAttempts = 30
|
||||
while !wasAlertText.exists {
|
||||
// Server Confirmation: Tap continue button
|
||||
app.buttons[A11yIdentifiers.serverConfirmationScreen.continue].tap()
|
||||
// Server Selection: Tap continue button
|
||||
app.buttons[A11yIdentifiers.changeServerScreen.continue].tap()
|
||||
|
||||
if wasAlertText.waitForExistence(timeout: 10) {
|
||||
break
|
||||
@@ -223,9 +196,11 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
|
||||
// Splash Screen: Tap get started button
|
||||
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
|
||||
|
||||
// Server Confirmation: Tap the picker and confirm
|
||||
app.switches.matching(identifier: A11yIdentifiers.serverConfirmationScreen.serverPicker).element(boundBy: 1).tap()
|
||||
app.buttons[A11yIdentifiers.serverConfirmationScreen.continue].tap()
|
||||
// Server Selection: Tap the second server in the picker and confirm.
|
||||
// Use descendants(matching: .any) since ListRow used outside a List produces an ambiguous
|
||||
// accessibility element type, making element-type-specific queries unreliable.
|
||||
app.descendants(matching: .any).matching(identifier: "example.com").firstMatch.tap()
|
||||
app.buttons[A11yIdentifiers.changeServerScreen.continue].tap()
|
||||
|
||||
// Login Screen: Wait for continue button to appear
|
||||
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:26d4cf939e3b6dbb78652c3480a2c329f423bf4f1853e3870d282b78d9a39bf3
|
||||
size 166621
|
||||
oid sha256:9a1768669c060c14dc99eee7355f7c482417ed3f87040b4a6f9e6ec39de6a54b
|
||||
size 171208
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cbcdaaa2d6ddb37e331027c598b5a818ff025b21068dbb0982e3b7db2e1a5171
|
||||
size 170739
|
||||
oid sha256:a9048c7635c8e6d54a6f9cc0641fef1a7ef8d47bffbadbcff383221292dfc7a3
|
||||
size 178955
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab299696c4358f35d57b48a3dad5d783c1776e23e81b67e915c69e1fca021dc5
|
||||
size 115595
|
||||
oid sha256:32ab691123f12169747b3ee8fa26ec6cea48478aa87c607b40de6f7665cd6e07
|
||||
size 107836
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e4ee7497137b730cc1e62740ccf7d90756002ac9f72e53de9251f0be070af28d
|
||||
size 133905
|
||||
oid sha256:c30d4677ac36d40b6cf1fd654c1ee13ffdad5533a5d49dac68c62e94b951da2d
|
||||
size 119739
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e59419f59671ec5fdaf2bc817e29456ef318de0e89f574b087a92b9437a15d56
|
||||
size 96869
|
||||
oid sha256:90df1f4842b0f49551d39016fcb33a95a910a92655f6842a116443a78d4caf61
|
||||
size 101227
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:785e95a2df0dbf82654d9d10007ee211e4532f9e6f72130b8d361dcb50206876
|
||||
size 105940
|
||||
oid sha256:43cb650082cb249d296df4cd72dc1859eaf4a3a6d2f6ccf1e92d0984cab52191
|
||||
size 109976
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// 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 HomeserverHistoryManagerTests {
|
||||
@Test
|
||||
func matchExistingServer() {
|
||||
// given a configured manager
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"example.com"
|
||||
])
|
||||
|
||||
// when querying a value that matches something in the list
|
||||
// then it returns that value without case sensitiveity
|
||||
#expect(manager.server(matchingPrefix: "ma") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "Ma") == "matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
func matchExistingServerinLRUOrder() {
|
||||
// given a configured manager with multiple values with similar prefixes
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"mandibles.chomp"
|
||||
])
|
||||
|
||||
// when querying the common portions of the prefix
|
||||
// then the result is the most recent addition to the list
|
||||
#expect(manager.server(matchingPrefix: "m") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "ma") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "man") == "mandibles.chomp")
|
||||
}
|
||||
|
||||
@Test
|
||||
func serversAreAddedInLRUOrder() {
|
||||
// given a configured manager
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"mandibles.chomp"
|
||||
])
|
||||
|
||||
// when adding a new server
|
||||
manager.addServerToList("merch.com")
|
||||
|
||||
// then querying gives priority to the latest addition
|
||||
#expect(manager.server(matchingPrefix: "m") == "merch.com")
|
||||
#expect(manager.server(matchingPrefix: "ma") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "man") == "mandibles.chomp")
|
||||
}
|
||||
|
||||
@Test
|
||||
func serversAreUpdatedInLRUOrder() {
|
||||
// given a configured manager
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"mandibles.chomp",
|
||||
"merch.com"
|
||||
])
|
||||
|
||||
// then querying has its given priority
|
||||
#expect(manager.server(matchingPrefix: "m") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "me") == "merch.com")
|
||||
#expect(manager.server(matchingPrefix: "ma") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "man") == "mandibles.chomp")
|
||||
|
||||
// when adding an existing server (case insensitive)
|
||||
manager.addServerToList("MERCH.com")
|
||||
|
||||
// then querying is updated to give the latest prioity
|
||||
#expect(manager.server(matchingPrefix: "m") == "merch.com")
|
||||
#expect(manager.server(matchingPrefix: "me") == "merch.com")
|
||||
#expect(manager.server(matchingPrefix: "ma") == "matrix.org")
|
||||
#expect(manager.server(matchingPrefix: "man") == "mandibles.chomp")
|
||||
}
|
||||
|
||||
@Test func matchNothingOnMiss() {
|
||||
// given a configured manager
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"example.com"
|
||||
])
|
||||
|
||||
// when making a query that matches nothing in its list, then there are no matches
|
||||
#expect(manager.server(matchingPrefix: "mi") == nil)
|
||||
#expect(manager.server(matchingPrefix: "g") == nil)
|
||||
}
|
||||
|
||||
@Test func additionalServerIncludedInResults() {
|
||||
// given a configured manager
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"example.com"
|
||||
])
|
||||
#expect(manager.server(matchingPrefix: "f") == nil)
|
||||
|
||||
// when adding a new server
|
||||
manager.addServerToList("FOO.bar")
|
||||
|
||||
// then it will become available with subsequent matching queries
|
||||
#expect(manager.server(matchingPrefix: "f") == "foo.bar")
|
||||
#expect(manager.server(matchingPrefix: "F") == "foo.bar")
|
||||
}
|
||||
|
||||
@Test func removingServerIsGone() {
|
||||
// given a configured manager
|
||||
let manager = createManager(withServerHistory: [
|
||||
"matrix.org",
|
||||
"example.com"
|
||||
])
|
||||
|
||||
// when removing a server
|
||||
manager.removeServerFromList("example.com")
|
||||
|
||||
// then it is no longer available in query results
|
||||
#expect(manager.server(matchingPrefix: "e") == nil)
|
||||
// and doesn't affect the other server entries
|
||||
#expect(manager.server(matchingPrefix: "m") == "matrix.org")
|
||||
}
|
||||
|
||||
private func createManager(withServerHistory history: [String]) -> HomeserverHistoryManager {
|
||||
let appSettings = AppSettings.volatile()
|
||||
appSettings.previousServers = history
|
||||
|
||||
return HomeserverHistoryManager(appSettings: appSettings)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +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.
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
struct ServerConfirmationScreenViewStateTests {
|
||||
@Test
|
||||
func loginMessageString() {
|
||||
let matrixDotOrgLogin = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockMatrixDotOrg.address),
|
||||
authenticationFlow: .login)
|
||||
#expect(matrixDotOrgLogin.message == L10n.screenServerConfirmationMessageLoginMatrixDotOrg, "matrix.org should have a custom message.")
|
||||
|
||||
let elementDotIoLogin = ServerConfirmationScreenViewState(mode: .confirmation("element.io"),
|
||||
authenticationFlow: .login)
|
||||
#expect(elementDotIoLogin.message == L10n.screenServerConfirmationMessageLoginElementDotIo, "element.io should have a custom message.")
|
||||
|
||||
let otherLogin = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockOAuth.address),
|
||||
authenticationFlow: .login)
|
||||
#expect(otherLogin.message == "", "Other servers should not show a message.")
|
||||
|
||||
let pickerLogin = ServerConfirmationScreenViewState(mode: .picker(["element.io", "matrix.org"]),
|
||||
authenticationFlow: .login)
|
||||
#expect(pickerLogin.message == nil, "The picker mode should not show a message.")
|
||||
}
|
||||
|
||||
@Test
|
||||
func registerMessageString() {
|
||||
let matrixDotOrgRegister = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockMatrixDotOrg.address),
|
||||
authenticationFlow: .register)
|
||||
#expect(matrixDotOrgRegister.message == L10n.screenServerConfirmationMessageRegister, "The registration message should always be the same.")
|
||||
|
||||
let oAuthRegister = ServerConfirmationScreenViewState(mode: .confirmation(LoginHomeserver.mockOAuth.address),
|
||||
authenticationFlow: .register)
|
||||
#expect(oAuthRegister.message == L10n.screenServerConfirmationMessageRegister, "The registration message should always be the same.")
|
||||
}
|
||||
}
|
||||
@@ -1,398 +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.
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import MatrixRustSDKMocks
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
final class ServerConfirmationScreenViewModelTests {
|
||||
var clientFactory: AuthenticationClientFactoryMock!
|
||||
var client: ClientSDKMock!
|
||||
var service: AuthenticationServiceProtocol!
|
||||
|
||||
var viewModel: ServerConfirmationScreenViewModel!
|
||||
var context: ServerConfirmationScreenViewModel.Context {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
private let appSettings: AppSettings
|
||||
|
||||
init() {
|
||||
appSettings = AppSettings.volatile()
|
||||
}
|
||||
|
||||
// MARK: - Confirmation mode
|
||||
|
||||
@Test
|
||||
func confirmLoginWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured.
|
||||
setupViewModel(authenticationFlow: .login)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then a call to configure service should be made.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
}
|
||||
|
||||
@Test
|
||||
func confirmLoginAfterConfiguration() async throws {
|
||||
// Given a view model for login using a service that has already been configured (via the server selection screen).
|
||||
setupViewModel(authenticationFlow: .login)
|
||||
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else {
|
||||
Issue.record("The configuration should succeed.")
|
||||
return
|
||||
}
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configured homeserver should be used and no additional client should be built.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func confirmRegisterWithoutConfiguration() async throws {
|
||||
// Given a view model for registration using a service that hasn't been configured.
|
||||
setupViewModel(authenticationFlow: .register)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then a call to configure service should be made.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
|
||||
// #expect(client.urlForOauthOauthConfigurationPromptReceivedArguments?.prompt == .create)
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
}
|
||||
|
||||
@Test
|
||||
func confirmRegisterAfterConfiguration() async throws {
|
||||
// Given a view model for registration using a service that has already been configured (via the server selection screen).
|
||||
setupViewModel(authenticationFlow: .register)
|
||||
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .register) else {
|
||||
Issue.record("The configuration should succeed.")
|
||||
return
|
||||
}
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configured homeserver should be used and no additional client should be built.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
|
||||
// #expect(client.urlForOauthOauthConfigurationPromptReceivedArguments?.prompt == .create)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func confirmPasswordLoginWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured (against a server that doesn't support OAuth).
|
||||
setupViewModel(authenticationFlow: .login, supportsOAuth: false)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then a call to configure service should be made, but not for the OAuth URL.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
}
|
||||
|
||||
@Test
|
||||
func confirmPasswordLoginAfterConfiguration() async throws {
|
||||
// Given a view model for login using a service that has already been configured (via the server selection screen).
|
||||
setupViewModel(authenticationFlow: .login, supportsOAuth: false)
|
||||
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else {
|
||||
Issue.record("The configuration should succeed.")
|
||||
return
|
||||
}
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OAuth URL.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registrationNotSupportedAlert() async throws {
|
||||
// Given a view model for registration using a service that hasn't been configured and the default server doesn't support registration.
|
||||
// Note: We don't currently take the create prompt into account when determining registration support.
|
||||
setupViewModel(authenticationFlow: .register, supportsOAuth: false, supportsOAuthCreatePrompt: false)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(context.alertInfo == nil)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configuration should fail with an alert about not supporting registration.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(context.alertInfo?.id == .registration)
|
||||
}
|
||||
|
||||
@Test
|
||||
func loginNotSupportedAlert() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured and the default server doesn't support login.
|
||||
setupViewModel(authenticationFlow: .login, supportsOAuth: false, supportsOAuthCreatePrompt: false, supportsPasswordLogin: false)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(context.alertInfo == nil)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configuration should fail with an alert about not supporting login.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(context.alertInfo?.id == .login)
|
||||
}
|
||||
|
||||
@Test
|
||||
func elementProRequired() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured and the default server requires Element Pro.
|
||||
setupViewModel(authenticationFlow: .login, supportsOAuth: false, supportsOAuthCreatePrompt: false, supportsPasswordLogin: false, requiresElementPro: true)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(context.alertInfo == nil)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configuration should fail with an alert telling the user to download Element Pro.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(context.alertInfo?.id == .elementProRequired(serverName: "matrix.org"))
|
||||
}
|
||||
|
||||
// MARK: - Picker mode
|
||||
|
||||
@Test
|
||||
func pickerWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured.
|
||||
setupViewModel(authenticationFlow: .login, restrictedFlow: true)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then a call to configure service should be made.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
}
|
||||
|
||||
@Test
|
||||
func pickerAfterConfiguration() async throws {
|
||||
// Given a view model for login using a service that has already been configured (via the server selection screen).
|
||||
setupViewModel(authenticationFlow: .login, restrictedFlow: true)
|
||||
guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else {
|
||||
Issue.record("The configuration should succeed.")
|
||||
return
|
||||
}
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configured homeserver should be used and no additional client should be built.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pickerForPasswordLoginWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured (against a server that doesn't support OAuth).
|
||||
setupViewModel(authenticationFlow: .login, supportsOAuth: false, restrictedFlow: true)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then a call to configure service should be made, but not for the OAuth URL.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pickerForPasswordLoginAfterConfiguration() async throws {
|
||||
// Given a view model for login using a service that has already been configured (via the server selection screen).
|
||||
setupViewModel(authenticationFlow: .login, supportsOAuth: false, restrictedFlow: true)
|
||||
guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else {
|
||||
Issue.record("The configuration should succeed.")
|
||||
return
|
||||
}
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When continuing from the confirmation screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OAuth URL.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func setupViewModel(authenticationFlow: AuthenticationFlow,
|
||||
supportsOAuth: Bool = true,
|
||||
supportsOAuthCreatePrompt: Bool = true,
|
||||
supportsPasswordLogin: Bool = true,
|
||||
restrictedFlow: Bool = false,
|
||||
requiresElementPro: Bool = false) {
|
||||
var mode = ServerConfirmationScreenMode.confirmation("matrix.org")
|
||||
if restrictedFlow {
|
||||
appSettings.override(accountProviders: ["matrix.org", "beta.matrix.org"],
|
||||
allowOtherAccountProviders: false,
|
||||
hideBrandChrome: false,
|
||||
pushGatewayBaseURL: appSettings.pushGatewayBaseURL,
|
||||
oAuthRedirectURL: appSettings.oAuthRedirectURL,
|
||||
oAuthClientURIPath: appSettings.oAuthClientURIPath,
|
||||
websiteURL: appSettings.websiteURL,
|
||||
logoURL: appSettings.logoURL,
|
||||
copyrightURL: appSettings.copyrightURL,
|
||||
acceptableUseURL: appSettings.acceptableUseURL,
|
||||
privacyURL: appSettings.privacyURL,
|
||||
encryptionURL: appSettings.encryptionURL,
|
||||
deviceVerificationURL: appSettings.deviceVerificationURL,
|
||||
chatBackupDetailsURL: appSettings.chatBackupDetailsURL,
|
||||
identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL,
|
||||
historySharingDetailsURL: appSettings.historySharingDetailsURL,
|
||||
elementWebHosts: appSettings.elementWebHosts,
|
||||
accountProvisioningHost: appSettings.accountProvisioningHost,
|
||||
bugReportApplicationID: appSettings.bugReportApplicationID,
|
||||
analyticsTermsURL: appSettings.analyticsTermsURL,
|
||||
mapTilerConfiguration: AppSettings.bundledMapTilerConfiguration)
|
||||
mode = .picker(appSettings.accountProviders)
|
||||
}
|
||||
|
||||
// Manually create a configuration as the default homeserver address setting is immutable.
|
||||
client = ClientSDKMock(.init(oAuthLoginURL: supportsOAuth ? "https://account.matrix.org/authorize" : nil,
|
||||
supportsOAuthCreatePrompt: supportsOAuthCreatePrompt,
|
||||
supportsPasswordLogin: supportsPasswordLogin,
|
||||
elementWellKnown: requiresElementPro ? "{\"version\":1,\"enforce_element_pro\":true}" : nil))
|
||||
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["matrix.org": client])
|
||||
|
||||
clientFactory = AuthenticationClientFactoryMock(configuration)
|
||||
service = AuthenticationService(userSessionStore: UserSessionStoreMock(.init()),
|
||||
encryptionKeyProvider: EncryptionKeyProvider(),
|
||||
classicAppManager: nil,
|
||||
clientFactory: clientFactory,
|
||||
appSettings: appSettings,
|
||||
appHooks: AppHooks())
|
||||
|
||||
viewModel = ServerConfirmationScreenViewModel(authenticationService: service,
|
||||
mode: mode,
|
||||
authenticationFlow: authenticationFlow,
|
||||
appSettings: appSettings,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
// Add a fake window in order for the OAuth flow to continue
|
||||
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
|
||||
}
|
||||
}
|
||||
|
||||
private extension ServerConfirmationScreenViewState {
|
||||
var homeserverAddress: String {
|
||||
switch mode {
|
||||
case .confirmation(let accountProvider):
|
||||
accountProvider
|
||||
case .picker:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ServerConfirmationScreenViewModelAction {
|
||||
var isContinueWithOAuth: Bool {
|
||||
switch self {
|
||||
case .continueWithOAuth: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var isContinueWithPassword: Bool {
|
||||
switch self {
|
||||
case .continueWithPassword: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,17 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import MatrixRustSDKMocks
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
struct ServerSelectionScreenViewModelTests {
|
||||
private static let passwordOnlyServer = "example.com" // password only, no OAuth
|
||||
private static let pickerProviders = ["matrix.org", "beta.matrix.org", passwordOnlyServer]
|
||||
|
||||
var appSettings: AppSettings!
|
||||
var client: ClientSDKMock!
|
||||
var clientFactory: AuthenticationClientFactoryMock!
|
||||
var service: AuthenticationServiceProtocol!
|
||||
var viewModel: ServerSelectionScreenViewModelProtocol!
|
||||
@@ -22,13 +29,13 @@ struct ServerSelectionScreenViewModelTests {
|
||||
@Test
|
||||
mutating func selectForLogin() async throws {
|
||||
// Given a view model for login.
|
||||
setup(authenticationFlow: .login)
|
||||
try setup(authenticationFlow: .login)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
|
||||
// When selecting matrix.org.
|
||||
context.homeserverAddress = "matrix.org"
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .updated }
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
@@ -40,7 +47,7 @@ struct ServerSelectionScreenViewModelTests {
|
||||
@Test
|
||||
mutating func loginNotSupportedAlert() async throws {
|
||||
// Given a view model for login.
|
||||
setup(authenticationFlow: .login)
|
||||
try setup(authenticationFlow: .login)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(context.alertInfo == nil)
|
||||
@@ -59,13 +66,13 @@ struct ServerSelectionScreenViewModelTests {
|
||||
@Test
|
||||
mutating func selectForRegistration() async throws {
|
||||
// Given a view model for registration.
|
||||
setup(authenticationFlow: .register)
|
||||
try setup(authenticationFlow: .register)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
|
||||
// When selecting matrix.org.
|
||||
context.homeserverAddress = "matrix.org"
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .updated }
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
@@ -77,7 +84,7 @@ struct ServerSelectionScreenViewModelTests {
|
||||
@Test
|
||||
mutating func registrationNotSupportedAlert() async throws {
|
||||
// Given a view model for registration.
|
||||
setup(authenticationFlow: .register)
|
||||
try setup(authenticationFlow: .register)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(context.alertInfo == nil)
|
||||
@@ -96,7 +103,7 @@ struct ServerSelectionScreenViewModelTests {
|
||||
@Test
|
||||
mutating func elementProRequiredAlert() async throws {
|
||||
// Given a view model for login.
|
||||
setup(authenticationFlow: .login)
|
||||
try setup(authenticationFlow: .login)
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(context.alertInfo == nil)
|
||||
@@ -115,10 +122,10 @@ struct ServerSelectionScreenViewModelTests {
|
||||
@Test
|
||||
mutating func invalidServer() async throws {
|
||||
// Given a new instance of the view model.
|
||||
setup(authenticationFlow: .login)
|
||||
try setup(authenticationFlow: .login)
|
||||
#expect(!context.viewState.isShowingFooterError, "There should not be an error message for a new view model.")
|
||||
#expect(context.viewState.footerErrorMessage == nil, "There should not be an error message for a new view model.")
|
||||
#expect(String(context.viewState.footerMessage) == L10n.screenChangeServerFormNotice,
|
||||
#expect(String(context.viewState.footerMessage) == L10n.screenChangeServerTextfieldFooterLogin,
|
||||
"The standard footer message should be shown.")
|
||||
|
||||
// When attempting to discover an invalid server
|
||||
@@ -130,7 +137,7 @@ struct ServerSelectionScreenViewModelTests {
|
||||
// Then the footer should now be showing an error.
|
||||
#expect(context.viewState.isShowingFooterError, "The error message should be stored.")
|
||||
#expect(context.viewState.footerErrorMessage != nil, "The error message should be stored.")
|
||||
#expect(String(context.viewState.footerMessage) != L10n.screenChangeServerFormNotice,
|
||||
#expect(String(context.viewState.footerMessage) != L10n.screenChangeServerTextfieldFooterLogin,
|
||||
"The error message should be shown.")
|
||||
|
||||
// And when clearing the error.
|
||||
@@ -141,16 +148,204 @@ struct ServerSelectionScreenViewModelTests {
|
||||
|
||||
// Then the error message should now be removed.
|
||||
#expect(context.viewState.footerErrorMessage == nil, "The error message should have been cleared.")
|
||||
#expect(String(context.viewState.footerMessage) == L10n.screenChangeServerFormNotice,
|
||||
#expect(String(context.viewState.footerMessage) == L10n.screenChangeServerTextfieldFooterLogin,
|
||||
"The standard footer message should be shown again.")
|
||||
}
|
||||
|
||||
// MARK: - userInput mode
|
||||
|
||||
@Test
|
||||
mutating func userInputPasswordLoginWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured against a server that doesn't support OAuth.
|
||||
try setup(authenticationFlow: .login)
|
||||
context.homeserverAddress = Self.passwordOnlyServer
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
|
||||
// When confirming from the server selection screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should be configured but no OAuth URL fetched.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func userInputPasswordLoginAfterConfiguration() async throws {
|
||||
// Given a view model for login using a service that has already been configured against a server that doesn't support OAuth.
|
||||
try setup(authenticationFlow: .login)
|
||||
context.homeserverAddress = Self.passwordOnlyServer
|
||||
guard case .success = await service.configure(for: context.homeserverAddress, flow: .login) else {
|
||||
Issue.record("The configuration should succeed.")
|
||||
return
|
||||
}
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
|
||||
// When confirming from the server selection screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should be re-configured but no OAuth URL fetched.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
|
||||
}
|
||||
|
||||
// MARK: - Picker mode
|
||||
|
||||
@Test
|
||||
mutating func pickerWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured.
|
||||
try setup(authenticationFlow: .login, mode: .picker(Self.pickerProviders))
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(context.viewState.mode == .picker(Self.pickerProviders))
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
// When confirming from the picker.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should be configured and the OAuth URL fetched.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
|
||||
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
|
||||
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func pickerForPasswordLoginWithoutConfiguration() async throws {
|
||||
// Given a view model for login using a service that hasn't been configured against a server that doesn't support OAuth.
|
||||
try setup(authenticationFlow: .login, mode: .picker(Self.pickerProviders))
|
||||
context.homeserverAddress = Self.passwordOnlyServer
|
||||
#expect(service.homeserver.value.loginMode == .unknown)
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
|
||||
|
||||
// When confirming from the picker.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
|
||||
context.send(viewAction: .confirm)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should be configured but no OAuth URL fetched.
|
||||
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||
#expect(service.homeserver.value.loginMode == .password)
|
||||
}
|
||||
|
||||
// MARK: - Autocomplete
|
||||
|
||||
@Test
|
||||
mutating func autocompleteMatchesPreviousServers() async throws {
|
||||
// Given a view model with a previous server in history.
|
||||
try setup(authenticationFlow: .login)
|
||||
appSettings.previousServers = ["myserver.com"]
|
||||
context.homeserverAddress = ""
|
||||
let textField = UITextField()
|
||||
context.send(viewAction: .updateTextField(textField))
|
||||
|
||||
// When the user types a prefix that matches the previous server.
|
||||
let typedSoFar = "my"
|
||||
let nextCharacter = "s"
|
||||
let typed = typedSoFar + nextCharacter
|
||||
let expectedAddress = "myserver.com"
|
||||
textField.text = typedSoFar
|
||||
let deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == expectedAddress }
|
||||
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: typedSoFar.count, length: 0), replacementString: nextCharacter)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the address should be completed and the appended portion selected.
|
||||
let selectionStart = expectedAddress.index(expectedAddress.startIndex, offsetBy: typed.count)
|
||||
#expect(context.homeserverAddress == expectedAddress)
|
||||
#expect(context.homeserverSelection == TextSelection(range: selectionStart..<expectedAddress.endIndex))
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func autocompleteFromAccountProviders() async throws {
|
||||
// Given a view model with no previous server history, falling back to the default account providers.
|
||||
try setup(authenticationFlow: .login)
|
||||
#expect(appSettings.previousServers.isEmpty)
|
||||
context.homeserverAddress = ""
|
||||
let textField = UITextField()
|
||||
context.send(viewAction: .updateTextField(textField))
|
||||
|
||||
// When the user types a prefix that matches an account provider.
|
||||
let typedSoFar = "ma"
|
||||
let nextCharacter = "t"
|
||||
let typed = typedSoFar + nextCharacter
|
||||
let expectedAddress = "matrix.org"
|
||||
textField.text = typedSoFar
|
||||
let deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == expectedAddress }
|
||||
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: typedSoFar.count, length: 0), replacementString: nextCharacter)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the address should be completed and the appended portion selected.
|
||||
let selectionStart = expectedAddress.index(expectedAddress.startIndex, offsetBy: typed.count)
|
||||
#expect(context.homeserverAddress == expectedAddress)
|
||||
#expect(context.homeserverSelection == TextSelection(range: selectionStart..<expectedAddress.endIndex))
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func autocompleteNoMatch() async throws {
|
||||
// Given a view model with a known set of servers.
|
||||
try setup(authenticationFlow: .login)
|
||||
appSettings.previousServers = ["myserver.com"]
|
||||
context.homeserverAddress = ""
|
||||
let textField = UITextField()
|
||||
context.send(viewAction: .updateTextField(textField))
|
||||
|
||||
// When the user types a string that doesn't match any known server prefix.
|
||||
textField.text = "xy"
|
||||
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 2, length: 0), replacementString: "z")
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
|
||||
// Then the address should remain unchanged.
|
||||
#expect(context.homeserverAddress == "")
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func autocompleteLRUOrder() async throws {
|
||||
// Given a view model with an ordered server history.
|
||||
try setup(authenticationFlow: .login)
|
||||
appSettings.previousServers = ["abc.foo", "matrix.org", "mantis.asdf"]
|
||||
context.homeserverAddress = ""
|
||||
let textField = UITextField()
|
||||
context.send(viewAction: .updateTextField(textField))
|
||||
|
||||
// Step 1: type "m" — first "m" match in history is "matrix.org".
|
||||
textField.text = ""
|
||||
var deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == "matrix.org" }
|
||||
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "m")
|
||||
try await deferred.fulfill()
|
||||
#expect(context.homeserverAddress == "matrix.org")
|
||||
|
||||
// Step 2: type "a" replacing the highlighted suffix — still matches "matrix.org".
|
||||
// textField.text = "matrix.org", "atrix.org" highlighted (loc: 1, len: 9).
|
||||
deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == "matrix.org" }
|
||||
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 1, length: 9), replacementString: "a")
|
||||
try await deferred.fulfill()
|
||||
#expect(context.homeserverAddress == "matrix.org")
|
||||
|
||||
// Step 3: type "n" replacing the highlighted suffix — "man" now matches "mantis.asdf".
|
||||
// textField.text = "matrix.org", "trix.org" highlighted (loc: 2, len: 8).
|
||||
deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == "mantis.asdf" }
|
||||
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 2, length: 8), replacementString: "n")
|
||||
try await deferred.fulfill()
|
||||
#expect(context.homeserverAddress == "mantis.asdf")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private mutating func setup(authenticationFlow: AuthenticationFlow) {
|
||||
let appSettings = AppSettings.volatile()
|
||||
private mutating func setup(authenticationFlow: AuthenticationFlow,
|
||||
mode: ServerSelectionScreenMode = .userInput) throws {
|
||||
appSettings = AppSettings.volatile()
|
||||
|
||||
let factoryConfiguration = AuthenticationClientFactoryMock.Configuration()
|
||||
// matrix.org: OAuth. example.com: password only. server.net: no login. secure.gov: OAuth + Element Pro required.
|
||||
client = factoryConfiguration.homeserverClients["matrix.org"]
|
||||
clientFactory = AuthenticationClientFactoryMock(factoryConfiguration)
|
||||
|
||||
clientFactory = AuthenticationClientFactoryMock(.init())
|
||||
service = AuthenticationService(userSessionStore: UserSessionStoreMock(.init()),
|
||||
encryptionKeyProvider: EncryptionKeyProvider(),
|
||||
classicAppManager: nil,
|
||||
@@ -159,8 +354,29 @@ struct ServerSelectionScreenViewModelTests {
|
||||
appHooks: AppHooks())
|
||||
|
||||
viewModel = ServerSelectionScreenViewModel(authenticationService: service,
|
||||
mode: mode,
|
||||
authenticationFlow: authenticationFlow,
|
||||
appSettings: appSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.first as? UIWindowScene)
|
||||
viewModel.context.send(viewAction: .updateWindow(UIWindow(windowScene: scene)))
|
||||
}
|
||||
}
|
||||
|
||||
private extension ServerSelectionScreenViewModelAction {
|
||||
var isContinueWithOAuth: Bool {
|
||||
switch self {
|
||||
case .continueWithOAuth: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var isContinueWithPassword: Bool {
|
||||
switch self {
|
||||
case .continueWithPassword: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user