Show the multi-account announcement on the HomeScreen (#6265)

* Present the multi-account announcement when nothing else is showing

The user session flow owns onboarding, verification and settings, so it presents the announcement in the tab sheet slot with the lowest priority: once the verification state is known, and whenever onboarding, settings or a verification sheet is dismissed. Routes and sign-out dismiss settings without triggering it, and it is marked as seen when presented.

* Show the multi-account announcement on the home screen

Following review, the announcement is a best-effort sheet on the home
screen instead of a UserSessionFlowCoordinator state. It's presented when
the room list appears with the flag on, the announcement armed and the
session verified, and counts as seen once its content appears.

- Restore UserSessionFlowCoordinator to develop.
- HomeScreenViewModel presents the announcement through a binding and
  waits for a known verification state, so it never competes with the
  identity confirmation onboarding.
- Add account only closes the sheet until the add account flow exists.
- Rename MultiAccountAnnouncementScreen to MultiAccountAnnouncementView
  and drop its unused coordinator and view model protocol.
- Size the sheet from the measured navigation bar height and match the
  Figma spacing; the hard-coded 44pt pushed the content under the button.

* Present the multi-account announcement from the home screen view model

The announcement no longer needs its own view model: the view takes the
home screen's context and presents through a boolean binding, like the
home screen banners.

- Drop MultiAccountAnnouncementViewModel and its models. The view sends
  home screen actions to close or add an account and builds its own
  attributed subtitle.
- Check the verification state when the room list appears instead of
  waiting for it. Restored sessions already know it by then, unverified
  ones get the identity confirmation cover first and the room list
  appears again once it's dismissed. Nothing is left subscribed, so the
  sheet can no longer turn up later wherever the user is.
This commit is contained in:
Mauro
2026-10-07 12:29:51 +02:00
committed by GitHub
parent 82a933d1d8
commit f2eaa8e80d
22 changed files with 221 additions and 248 deletions
@@ -403,8 +403,8 @@ extension AccessibilityTests {
try await performAccessibilityAudit(named: "ModerationAndSafetySettingsScreen_Previews")
}
func testMultiAccountAnnouncementScreen() async throws {
try await performAccessibilityAudit(named: "MultiAccountAnnouncementScreen_Previews")
func testMultiAccountAnnouncementView() async throws {
try await performAccessibilityAudit(named: "MultiAccountAnnouncementView_Previews")
}
func testNoticeRoomTimelineView() async throws {
+4 -36
View File
@@ -533,7 +533,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 */; };
53A182049BEEC6195B91DD79 /* MultiAccountAnnouncementScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69630272FD26D4E5974CC88E /* MultiAccountAnnouncementScreenViewModelProtocol.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 */; };
@@ -1045,7 +1044,6 @@
A0646F23876D6326AD27FF52 /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; };
A07178337F3C0B208B5A77A8 /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB6ED50FE104992419310EEB /* NotificationHandler.swift */; };
A076E0A9338FD2D950C3C4A1 /* ChatsSpaceFiltersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E63072981793CCA84EE12798 /* ChatsSpaceFiltersScreenViewModelProtocol.swift */; };
A0790A97DAD44E8895035704 /* MultiAccountAnnouncementScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD46D21FF370EDF6BE87F8A7 /* MultiAccountAnnouncementScreenCoordinator.swift */; };
A0861B727B273B5B3DD7FBF6 /* KnockRequestsListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */; };
A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */; };
A0D7E5BD0298A97DCBDCE40B /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; };
@@ -1103,6 +1101,7 @@
A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; };
A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; };
A88328D7E17F73AB64501B51 /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 391D11F92DFC91666AA1503F /* SwiftOGG */; };
A88A0C11EB4231EE2989C659 /* MultiAccountAnnouncementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5BE5CAC17E09EE2643D97F2 /* MultiAccountAnnouncementView.swift */; };
A8DB299C5C891EDAE74A22F4 /* UserLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7482D5D1526E3399B00174 /* UserLocationCell.swift */; };
A8E324E700E596E36B0A311B /* BootDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */; };
A8FA7671948E3DF27F320026 /* BugReportFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */; };
@@ -1289,7 +1288,6 @@
C8E11A335456FCF94A744E6E /* SpaceFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */; };
C8E1E4E06B7C7A3A8246FC9B /* MediaEventsTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */; };
C900127318820AD04D6C90B8 /* LabsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E43D8784B0054C048060FEB /* LabsScreenModels.swift */; };
C90A5ABB868F7DDEE2DFD653 /* MultiAccountAnnouncementScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB71240A4E855DE7B9201FD /* MultiAccountAnnouncementScreenModels.swift */; };
C915347779B3C7FDD073A87A /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E1FF0DFBB3768F79FDBF6D /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift */; };
C95ABC529E0009926BDE5664 /* preview_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 44688A16934827A0D1668253 /* preview_image.jpg */; };
C960BACE42A9D8C535E8CB34 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1912062B53CE95E6F700DA60 /* Pagination.swift */; };
@@ -1422,7 +1420,6 @@
DCFE7CB3B9A104330BBB96AD /* AnalyticsPromptScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B67DF223EEB8DCAF178A1D4 /* AnalyticsPromptScreenCoordinator.swift */; };
DD21CE51DF9BD04FC8155972 /* LeaveSpaceHandleSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */; };
DD4D8B5D668197D345A3ED42 /* TimelineControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F933BB25FC426D5C67E1D812 /* TimelineControllerMock.swift */; };
DD66C863B4232BCDEBE883C8 /* MultiAccountAnnouncementScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B5FD54003D9527FE347516 /* MultiAccountAnnouncementScreen.swift */; };
DDE7B4771452300C103B1EB8 /* RoomDirectoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6210134203BE1F2DD5C679 /* RoomDirectoryCell.swift */; };
DDFBDEE1DC32BDD5488F898C /* ClientProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */; };
DE3BF0ED68E56BF625591D49 /* LinkNewDeviceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C4A2D279386A811BDC6DAE /* LinkNewDeviceScreen.swift */; };
@@ -1586,7 +1583,6 @@
F519DE17A3A0F760307B2E6D /* InviteUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */; };
F54E2D6CAD96E1AC15BC526F /* MessageForwardingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */; };
F58BF3BD3233A03F013816E4 /* ClassicAppAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */; };
F5A74869B9899FB178EDCDF9 /* MultiAccountAnnouncementScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F6FA4E8F2A0D6947AA44C2 /* MultiAccountAnnouncementScreenViewModel.swift */; };
F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */; };
F656F92A63D3DC1978D79427 /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = 401E93551F7792B0179907CF /* Macros */; };
F65F3909769E7C48B3309D9D /* RemoteSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D186A6DB8FAC5C9D0E4D61 /* RemoteSettingsHook.swift */; };
@@ -2086,7 +2082,6 @@
2DA3DBE1A42EAFF93889FA04 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/SAS.strings; sourceTree = "<group>"; };
2DA4F09CB613C54FDC73AE6A /* ThreadDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDecorator.swift; sourceTree = "<group>"; };
2DB0E533508094156D8024C3 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = "<group>"; };
2DB71240A4E855DE7B9201FD /* MultiAccountAnnouncementScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiAccountAnnouncementScreenModels.swift; sourceTree = "<group>"; };
2E11E7C396ED06A154CF6DF3 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/SAS.strings; sourceTree = "<group>"; };
2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineView.swift; sourceTree = "<group>"; };
2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = "<group>"; };
@@ -2204,7 +2199,6 @@
42C58159916ABD7F454CE689 /* AdvancedSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenCoordinator.swift; sourceTree = "<group>"; };
42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenModels.swift; sourceTree = "<group>"; };
42C8C368A611B9CB79C7F5FA /* RoomPollsHistoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreen.swift; sourceTree = "<group>"; };
42F6FA4E8F2A0D6947AA44C2 /* MultiAccountAnnouncementScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiAccountAnnouncementScreenViewModel.swift; sourceTree = "<group>"; };
430C73079A84654BF46A7FF5 /* FileMediaEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMediaEventsTimelineView.swift; sourceTree = "<group>"; };
4338BB055A26094673CDC220 /* SpaceAddRoomsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceAddRoomsScreenViewModel.swift; sourceTree = "<group>"; };
434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -2432,7 +2426,6 @@
68F8744C32ACA0821A771EBB /* test_audio.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = test_audio.mp3; sourceTree = "<group>"; };
690915157A874D1F21ADE70C /* preview_avatar_room.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = preview_avatar_room.jpg; sourceTree = "<group>"; };
693E16574C6F7F9FA1015A8C /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = "<group>"; };
69630272FD26D4E5974CC88E /* MultiAccountAnnouncementScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiAccountAnnouncementScreenViewModelProtocol.swift; sourceTree = "<group>"; };
69A05E85E4872C3221C5C287 /* RemotePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePreference.swift; sourceTree = "<group>"; };
69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregatedReactionMock.swift; sourceTree = "<group>"; };
69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModelTests.swift; sourceTree = "<group>"; };
@@ -2691,7 +2684,6 @@
94028A227645FA880B966211 /* WaveformSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformSource.swift; sourceTree = "<group>"; };
9475FD81B13D50103E5290EB /* SpaceSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceSettingsScreen.swift; sourceTree = "<group>"; };
94A561C542596126DAE211AD /* preview_avatar_user.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = preview_avatar_user.jpg; sourceTree = "<group>"; };
94B5FD54003D9527FE347516 /* MultiAccountAnnouncementScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiAccountAnnouncementScreen.swift; sourceTree = "<group>"; };
94D670124FC3E84F23A62CCF /* APNSPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSPayload.swift; sourceTree = "<group>"; };
94EE2C5F0A06F146BBE3A1B1 /* LinkNewDeviceFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceFlowCoordinator.swift; sourceTree = "<group>"; };
9501D11B4258DFA33BA3B40F /* ServerSelectionScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenModels.swift; sourceTree = "<group>"; };
@@ -2791,6 +2783,7 @@
A4D9DF4F2DF3507F99B5B97B /* LabsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenViewModel.swift; sourceTree = "<group>"; };
A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineFlowCoordinator.swift; sourceTree = "<group>"; };
A566F8F2C27B99E1FB80C69B /* JoinRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRule.swift; sourceTree = "<group>"; };
A5BE5CAC17E09EE2643D97F2 /* MultiAccountAnnouncementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiAccountAnnouncementView.swift; sourceTree = "<group>"; };
A5C45578F406698388B97C07 /* AuthenticationClassicAppAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationClassicAppAccountView.swift; sourceTree = "<group>"; };
A63A7C1486AA8FF088B1701C /* ModerationAndSafetySettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModerationAndSafetySettingsScreenCoordinator.swift; sourceTree = "<group>"; };
A67BCEFE35F8D00CADF108C9 /* AccountProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountProvider.swift; sourceTree = "<group>"; };
@@ -3046,7 +3039,6 @@
CCE06F4A71FD46C9D8CD432E /* RoomThreadListServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListServiceProxy.swift; sourceTree = "<group>"; };
CCF71646898A2F720C5BFDF5 /* RoomDirectorySearchScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenViewModel.swift; sourceTree = "<group>"; };
CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreen.swift; sourceTree = "<group>"; };
CD46D21FF370EDF6BE87F8A7 /* MultiAccountAnnouncementScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiAccountAnnouncementScreenCoordinator.swift; sourceTree = "<group>"; };
CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineItemContent.swift; sourceTree = "<group>"; };
CDA38C9B58693A49A3032552 /* DeclineAndBlockScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenCoordinator.swift; sourceTree = "<group>"; };
CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = "<group>"; };
@@ -4016,14 +4008,6 @@
path = RoomMember;
sourceTree = "<group>";
};
2C5E4DC51BC09EA05CA1D590 /* View */ = {
isa = PBXGroup;
children = (
94B5FD54003D9527FE347516 /* MultiAccountAnnouncementScreen.swift */,
);
path = View;
sourceTree = "<group>";
};
2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */ = {
isa = PBXGroup;
children = (
@@ -4860,6 +4844,7 @@
05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */,
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */,
C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */,
A5BE5CAC17E09EE2643D97F2 /* MultiAccountAnnouncementView.swift */,
037A5661B26EC6BE068188D7 /* Filters */,
);
path = View;
@@ -5086,18 +5071,6 @@
path = View;
sourceTree = "<group>";
};
601F598160369994A6C0C85D /* MultiAccountAnnouncementScreen */ = {
isa = PBXGroup;
children = (
CD46D21FF370EDF6BE87F8A7 /* MultiAccountAnnouncementScreenCoordinator.swift */,
2DB71240A4E855DE7B9201FD /* MultiAccountAnnouncementScreenModels.swift */,
42F6FA4E8F2A0D6947AA44C2 /* MultiAccountAnnouncementScreenViewModel.swift */,
69630272FD26D4E5974CC88E /* MultiAccountAnnouncementScreenViewModelProtocol.swift */,
2C5E4DC51BC09EA05CA1D590 /* View */,
);
path = MultiAccountAnnouncementScreen;
sourceTree = "<group>";
};
605F8221E52991786397FCC9 /* View */ = {
isa = PBXGroup;
children = (
@@ -7248,7 +7221,6 @@
87E2774157D9C4894BCFF3F8 /* MediaPickerScreen */,
23605DD08620BE6558242469 /* MediaUploadPreviewScreen */,
3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */,
601F598160369994A6C0C85D /* MultiAccountAnnouncementScreen */,
DE07A244ACE83E40D894EDF3 /* NativeCallScreen */,
8F074E22FD93E64211971845 /* Onboarding */,
A448A3A8F764174C60CD0CA1 /* Other */,
@@ -9287,11 +9259,7 @@
70A4C56E4E6C60A3124631FC /* ModerationAndSafetySettingsScreenModels.swift in Sources */,
5846723185B444D52E077874 /* ModerationAndSafetySettingsScreenViewModel.swift in Sources */,
7C8F9E3AF56E7FF5B338DBFA /* ModerationAndSafetySettingsScreenViewModelProtocol.swift in Sources */,
DD66C863B4232BCDEBE883C8 /* MultiAccountAnnouncementScreen.swift in Sources */,
A0790A97DAD44E8895035704 /* MultiAccountAnnouncementScreenCoordinator.swift in Sources */,
C90A5ABB868F7DDEE2DFD653 /* MultiAccountAnnouncementScreenModels.swift in Sources */,
F5A74869B9899FB178EDCDF9 /* MultiAccountAnnouncementScreenViewModel.swift in Sources */,
53A182049BEEC6195B91DD79 /* MultiAccountAnnouncementScreenViewModelProtocol.swift in Sources */,
A88A0C11EB4231EE2989C659 /* MultiAccountAnnouncementView.swift in Sources */,
E938F7A45D6A3DBBE6789A03 /* NSEUserSession.swift in Sources */,
551E03A935A6B73158A1AB4A /* NSEUserSessionMock.swift in Sources */,
F9842667B68DC6FA1F9ECCBB /* NSItemProvider.swift in Sources */,
@@ -105,7 +105,7 @@ enum TestablePreviewsDictionary {
"MessageForwardingScreen_Previews" : MessageForwardingScreen_Previews.self,
"MessageText_Previews" : MessageText_Previews.self,
"ModerationAndSafetySettingsScreen_Previews" : ModerationAndSafetySettingsScreen_Previews.self,
"MultiAccountAnnouncementScreen_Previews" : MultiAccountAnnouncementScreen_Previews.self,
"MultiAccountAnnouncementView_Previews" : MultiAccountAnnouncementView_Previews.self,
"NoticeRoomTimelineView_Previews" : NoticeRoomTimelineView_Previews.self,
"NotificationPermissionsScreen_Previews" : NotificationPermissionsScreen_Previews.self,
"NotificationSettingsEditScreenRoomCell_Previews" : NotificationSettingsEditScreenRoomCell_Previews.self,
@@ -42,6 +42,10 @@ enum HomeScreenViewAction {
case resetEncryption
case skipRecoveryKeyConfirmation
case dismissNewSoundBanner
case screenAppeared
case multiAccountAnnouncementAppeared
case dismissMultiAccountAnnouncement
case addAccount
case updateVisibleItemRange(Range<Int>)
case spaceFilters
case markRoomAsUnread(roomIdentifier: String)
@@ -161,6 +165,7 @@ struct HomeScreenViewStateBindings {
var leaveRoomAlertItem: LeaveRoomAlertItem?
var spaceFiltersViewModel: ChatsSpaceFiltersScreenViewModel?
var isPresentingMultiAccountAnnouncement = false
}
enum CallBadgeType {
@@ -218,6 +218,14 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
state.securityBannerMode = .dismissed
case .dismissNewSoundBanner:
userSettings.hasSeenNewSoundBanner = true
case .screenAppeared:
// Use a task otherwise the presentation isn't animated.
Task { presentMultiAccountAnnouncementIfNeeded() }
case .multiAccountAnnouncementAppeared:
MXLog.info("The multi-account announcement has been seen.")
userSettings.hasSeenMultiAccountAnnouncement = true
case .dismissMultiAccountAnnouncement, .addAccount: // There is no add account flow to start yet.
state.bindings.isPresentingMultiAccountAnnouncement = false
case .updateVisibleItemRange(let range):
roomSummaryProvider?.updateVisibleRange(range)
case .startChat:
@@ -476,6 +484,21 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
actionsSubject.send(.roomLeft(roomIdentifier: roomID))
}
// MARK: Multi-account announcement
private func presentMultiAccountAnnouncementIfNeeded() {
// An unverified session gets the identity confirmation cover first, the room list appears again once it's dismissed.
guard userSettings.multiAccountEnabled,
!userSettings.hasSeenMultiAccountAnnouncement,
userSession.sessionSecurityStatePublisher.value.verificationState == .verified,
!state.bindings.isPresentingMultiAccountAnnouncement else {
return
}
MXLog.info("Presenting the multi-account announcement.")
state.bindings.isPresentingMultiAccountAnnouncement = true
}
// MARK: Invites
private func acceptInvite(roomID: String) async {
@@ -42,6 +42,11 @@ struct HomeScreen: View {
.navigationTransition(.zoom(sourceID: NavigationTransitionSourceID.spaceFilters,
in: navigationTransitionNamespace))
}
.sheet(isPresented: $context.isPresentingMultiAccountAnnouncement) {
MultiAccountAnnouncementView(context: context)
.onAppear { context.send(viewAction: .multiAccountAnnouncementAppeared) }
}
.onAppear { context.send(viewAction: .screenAppeared) }
}
// MARK: - Private
@@ -0,0 +1,85 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct MultiAccountAnnouncementView: View {
let context: HomeScreenViewModel.Context
@State private var navigationBarHeight: CGFloat = .zero
@State private var contentHeight: CGFloat = .zero
@State private var buttonHeight: CGFloat = .zero
private var subtitle: AttributedString {
let boldPlaceholder = "{bold}"
var subtitle = AttributedString(L10n.screenMultiAccountAnnouncementDescription(boldPlaceholder))
var boldString = AttributedString("\(L10n.commonSettings) > \(L10n.screenSettingsAddAccount)")
boldString.bold()
subtitle.replace(boldPlaceholder, with: boldString)
return subtitle
}
var body: some View {
GeometryReader { geometry in
ElementNavigationStack {
ScrollView {
content
.readHeight($contentHeight)
}
.scrollBounceBehavior(.basedOnSize)
.backportSafeAreaBar(edge: .bottom, spacing: 0) {
addAccountButton
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
.readHeight($buttonHeight)
}
.onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { navigationBarHeight = $0 }
.toolbar {
ToolbarItem(placement: .primaryAction) {
ToolbarButton(role: .close) { context.send(viewAction: .dismissMultiAccountAnnouncement) }
}
}
}
}
.presentationDetents([.height(navigationBarHeight + contentHeight + buttonHeight)])
.presentationDragIndicator(.hidden)
.presentationBackground(.compound.bgCanvasDefault)
}
private var content: some View {
TitleAndIcon(title: L10n.screenMultiAccountAnnouncementTitle,
subtitle: subtitle,
icon: \.userProfileSolid,
iconStyle: .defaultSolid)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.top, 8)
.padding(.bottom, 24)
}
private var addAccountButton: some View {
Button {
context.send(viewAction: .addAccount)
} label: {
Label(L10n.screenMultiAccountAnnouncementAction, icon: \.plus)
}
.buttonStyle(.compound(.primary))
.padding(.horizontal, 16)
.padding(.top, 16)
}
}
// MARK: - Previews
struct MultiAccountAnnouncementView_Previews: PreviewProvider, TestablePreview {
static let viewModel = HomeScreen_Previews.viewModel(.rooms)
static var previews: some View {
MultiAccountAnnouncementView(context: viewModel.context)
.previewDisplayName("Default")
}
}
@@ -1,47 +0,0 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
enum MultiAccountAnnouncementScreenCoordinatorAction {
case addAccount
case dismiss
}
final class MultiAccountAnnouncementScreenCoordinator: CoordinatorProtocol {
private let viewModel: MultiAccountAnnouncementScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<MultiAccountAnnouncementScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<MultiAccountAnnouncementScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init() {
viewModel = MultiAccountAnnouncementScreenViewModel()
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .addAccount:
actionsSubject.send(.addAccount)
case .dismiss:
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(MultiAccountAnnouncementScreen(context: viewModel.context))
}
}
@@ -1,31 +0,0 @@
//
// 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 SwiftUI
enum MultiAccountAnnouncementScreenViewModelAction {
case addAccount
case dismiss
}
struct MultiAccountAnnouncementScreenViewState: BindableState {
let description: AttributedString
init() {
let boldPlaceholder = "{bold}"
var description = AttributedString(L10n.screenMultiAccountAnnouncementDescription(boldPlaceholder))
var boldString = AttributedString("\(L10n.commonSettings) > \(L10n.screenSettingsAddAccount)")
boldString.bold()
description.replace(boldPlaceholder, with: boldString)
self.description = description
}
}
enum MultiAccountAnnouncementScreenViewAction {
case addAccount
case close
}
@@ -1,33 +0,0 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias MultiAccountAnnouncementScreenViewModelType = StateStoreViewModelV2<MultiAccountAnnouncementScreenViewState, MultiAccountAnnouncementScreenViewAction>
class MultiAccountAnnouncementScreenViewModel: MultiAccountAnnouncementScreenViewModelType, MultiAccountAnnouncementScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<MultiAccountAnnouncementScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<MultiAccountAnnouncementScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init() {
super.init(initialViewState: .init())
}
// MARK: - Public
override func process(viewAction: MultiAccountAnnouncementScreenViewAction) {
switch viewAction {
case .addAccount:
actionsSubject.send(.addAccount)
case .close:
actionsSubject.send(.dismiss)
}
}
}
@@ -1,13 +0,0 @@
//
// 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
protocol MultiAccountAnnouncementScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<MultiAccountAnnouncementScreenViewModelAction, Never> { get }
var context: MultiAccountAnnouncementScreenViewModelType.Context { get }
}
@@ -1,71 +0,0 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct MultiAccountAnnouncementScreen: View {
let context: MultiAccountAnnouncementScreenViewModel.Context
@State private var contentHeight: CGFloat = .zero
@State private var buttonHeight: CGFloat = .zero
private let topPadding: CGFloat = 44 // For the navigation bar
var body: some View {
ElementNavigationStack {
ScrollView {
content
.readHeight($contentHeight)
}
.scrollBounceBehavior(.basedOnSize)
.backportSafeAreaBar(edge: .bottom, spacing: 0) {
addAccountButton
.readHeight($buttonHeight)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
ToolbarButton(role: .close) { context.send(viewAction: .close) }
}
}
}
.presentationDetents([.height(contentHeight + buttonHeight + topPadding)])
.presentationDragIndicator(.hidden)
.presentationBackground(.compound.bgCanvasDefault)
}
private var content: some View {
TitleAndIcon(title: L10n.screenMultiAccountAnnouncementTitle,
subtitle: context.viewState.description,
icon: \.userProfileSolid,
iconStyle: .defaultSolid)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 24)
}
private var addAccountButton: some View {
Button {
context.send(viewAction: .addAccount)
} label: {
Label(L10n.screenMultiAccountAnnouncementAction, icon: \.plus)
}
.buttonStyle(.compound(.primary))
.padding(16)
}
}
// MARK: - Previews
struct MultiAccountAnnouncementScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = MultiAccountAnnouncementScreenViewModel()
static var previews: some View {
MultiAccountAnnouncementScreen(context: viewModel.context)
.previewDisplayName("Default")
}
}
@@ -705,8 +705,8 @@ extension PreviewTests {
}
@Test
func multiAccountAnnouncementScreen() async throws {
for (index, preview) in MultiAccountAnnouncementScreen_Previews._allPreviews.enumerated() {
func multiAccountAnnouncementView() async throws {
for (index, preview) in MultiAccountAnnouncementView_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:257f3948490a4a9ca4b9f2eefebb3f0c010bfb3fae2721e4b95cab52889fe3a5
size 102613
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2588daf8fa9a61b445545e047799520bbd20388a5dbd3f2a71ad94438aee6149
size 118735
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:753970b5fa26d39052106d90d2f6ecc807a15eddf031f5f67c6e7457d188675a
size 63416
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d987ab496eec5bfe41d3c1b8f2143da225b5740c7b79eb75d4dfaa7fa4b379c2
size 85276
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7967692a14b42d638ab62437ffeddb16e3256d416c601447dc0bedb13f3047f2
size 102541
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c5532a443cbd0cb308a854685d4e3a6f0025127592c11d195f6b0a0f89641c4
size 118672
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31d9c55229100d0b51bfeaa91fd5fb2800a36da17d4a38b794a057070bfb0ec2
size 63289
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eae27d5df83718a448c85764713f2bea34367933885aaf484028eb6534207520
size 85098
@@ -380,10 +380,92 @@ final class HomeScreenViewModelTests {
#expect(userSettings.hasSeenNewSoundBanner)
}
@Test
func multiAccountAnnouncement() async throws {
userSettings.multiAccountEnabled = true
setupViewModel()
// Arming it after the view model exists (Developer options) doesn't present it on its own.
userSettings.hasSeenMultiAccountAnnouncement = false
let deferredFailure = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
try await deferredFailure.fulfill()
let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferred.fulfill()
#expect(!userSettings.hasSeenMultiAccountAnnouncement)
context.send(viewAction: .multiAccountAnnouncementAppeared)
#expect(userSettings.hasSeenMultiAccountAnnouncement)
#expect(context.viewState.bindings.isPresentingMultiAccountAnnouncement)
// A swipe down dismisses it without any of the announcement's actions.
context.isPresentingMultiAccountAnnouncement = false
let deferredReappearance = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferredReappearance.fulfill()
}
@Test(arguments: [HomeScreenViewAction.dismissMultiAccountAnnouncement, .addAccount])
func multiAccountAnnouncementActionsDismissIt(_ action: HomeScreenViewAction) async throws {
userSettings.multiAccountEnabled = true
userSettings.hasSeenMultiAccountAnnouncement = false
setupViewModel()
let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferred.fulfill()
context.send(viewAction: action)
#expect(!context.viewState.bindings.isPresentingMultiAccountAnnouncement)
}
@Test(arguments: MultiAccountAnnouncementHiddenCase.allCases)
func multiAccountAnnouncementHidden(_ hiddenCase: MultiAccountAnnouncementHiddenCase) async throws {
userSettings.multiAccountEnabled = hiddenCase != .flagOff
userSettings.hasSeenMultiAccountAnnouncement = hiddenCase == .alreadySeen
let verificationState: SessionVerificationState = switch hiddenCase {
case .unverified: .unverified
case .unknownVerificationState: .unknown
case .flagOff, .alreadySeen: .verified
}
let securityStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: verificationState, recoveryState: .enabled))
setupViewModel(securityStatePublisher: securityStateSubject.asCurrentValuePublisher())
let deferred = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferred.fulfill()
#expect(userSettings.hasSeenMultiAccountAnnouncement == (hiddenCase == .alreadySeen))
}
@Test
func multiAccountAnnouncementPresentsOnTheNextAppearanceOnceVerified() async throws {
userSettings.multiAccountEnabled = true
userSettings.hasSeenMultiAccountAnnouncement = false
let securityStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .unverified, recoveryState: .enabled))
setupViewModel(securityStatePublisher: securityStateSubject.asCurrentValuePublisher())
let deferredFailure = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferredFailure.fulfill()
// Becoming verified doesn't present it on its own…
securityStateSubject.send(.init(verificationState: .verified, recoveryState: .enabled))
let deferredVerifiedFailure = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
try await deferredVerifiedFailure.fulfill()
// …the room list appearing again does, e.g. once the identity confirmation cover is dismissed.
let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferred.fulfill()
}
// MARK: - Helpers
enum InviteType { case rooms, spaces }
enum MultiAccountAnnouncementHiddenCase: CaseIterable { case flagOff, alreadySeen, unverified, unknownVerificationState }
@Test
func roomListModeIsSetSynchronouslyOnAWarmLaunch() {
setupViewModel()