From e986fad1e57515a7d946ca5b124da2efc4029172 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Fri, 14 Jul 2023 11:43:19 +0200 Subject: [PATCH] Add a "Mute" shortcut and a "Notifications" section in the room details screen #1318 --- ElementX.xcodeproj/project.pbxproj | 32 +++ .../en.lproj/Localizable.strings | 5 + .../RoomFlowCoordinator.swift | 13 +- ElementX/Sources/Generated/Strings.swift | 10 + .../Mocks/Generated/GeneratedMocks.swift | 267 ++++++++++++++++++ .../Mocks/Generated/SDKGeneratedMocks.swift | 1 + .../Mocks/NotificationSettingsProxyMock.swift | 35 +++ .../RoomNotificationSettingsProxyMock.swift | 32 +++ .../Other/AccessibilityIdentifiers.swift | 1 + .../RoomDetailsScreenCoordinator.swift | 9 +- .../RoomDetailsScreenModels.swift | 89 +++++- .../RoomDetailsScreenViewModel.swift | 79 +++++- .../View/RoomDetailsScreen.swift | 92 +++++- .../Sources/Services/Client/ClientProxy.swift | 7 + .../Services/Client/ClientProxyProtocol.swift | 2 + .../Services/Client/MockClientProxy.swift | 9 + .../NotificationSettingsProxy.swift | 122 ++++++++ .../NotificationSettingsProxyProtocol.swift | 41 +++ .../RoomNotificationSettingsProxy.swift | 34 +++ ...oomNotificationSettingsProxyProtocol.swift | 24 ++ .../UITests/UITestsAppCoordinator.swift | 15 +- .../Sources/RoomDetailsViewModelTests.swift | 242 +++++++++++++++- 22 files changed, 1118 insertions(+), 43 deletions(-) create mode 100644 ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift create mode 100644 ElementX/Sources/Mocks/RoomNotificationSettingsProxyMock.swift create mode 100644 ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift create mode 100644 ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxyProtocol.swift create mode 100644 ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxy.swift create mode 100644 ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxyProtocol.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9b8132079..43a15c8f0 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ 4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052B2F924572AFD70B5F500E /* StartChatScreenViewModel.swift */; }; 4AAA8606FBA290E23D15422E /* AvatarHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */; }; 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */; }; + 4BAB8222DBA0B4207D1223E0 /* NotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */; }; 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */; }; 4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */; }; 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */; }; @@ -353,6 +354,7 @@ 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; 84C0CF78BCE085C08CB94D86 /* TimelineEventProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B62EE933FC3D5651AF4607 /* TimelineEventProxy.swift */; }; 84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; + 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */; }; 854E82E064BA53CD0BC45600 /* LocationRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */; }; 85813D87DDD7F67A46BD9AF7 /* ImageProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E8A8047B50E3607ACD354E /* ImageProviderProtocol.swift */; }; 858276B19C7C0AD4CA98EA78 /* portrait_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = AF042B0FB2EE88977C91E330 /* portrait_test_image.jpg */; }; @@ -453,6 +455,7 @@ A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */; }; A14A9419105A1CD42F0511C4 /* UserIndicatorModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */; }; A17FAD2EBC53E17B5FD384DB /* InviteUsersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */; }; + A1BA8D6BABAFA9BAAEAA3FFD /* NotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */; }; A1D4033881320C9EB88196E6 /* ServerConfirmationScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE846DDA83BFD7EC5C03760B /* ServerConfirmationScreenUITests.swift */; }; A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */; }; A216C83ADCF32BA5EF8A6FBC /* InviteUsersViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DDBDE5A0887E73D38B826 /* InviteUsersViewModelTests.swift */; }; @@ -534,6 +537,7 @@ B80C4FABB5529DF12436FFDA /* AppIcon.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */; }; B8C316C6CA24512DFE9A27FD /* TimelineItemSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55AEEF8142DF1B59DB40FB93 /* TimelineItemSender.swift */; }; B93D7CE520088AD53FA6D53C /* SettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B663BE498BB39EADC24025D /* SettingsScreenModels.swift */; }; + B93FA0DA1504B301CAEE141B /* NotificationSettingsProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */; }; B94368839BDB69172E28E245 /* MXLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111B698739E3410E2CDB7144 /* MXLog.swift */; }; B98A20A093A4FB785BFCCA53 /* BugReportScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FBD9C2B9A5479526920399 /* BugReportScreenCoordinator.swift */; }; B9A8C34A00D03094C0CF56F3 /* MediaUploadPreviewScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */; }; @@ -599,6 +603,7 @@ CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5E97E9615A158C76B2AB77 /* DateTests.swift */; }; CD6A72B65D3B6076F4045C30 /* PHGPostHogConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */; }; CE1694C7BB93C3311524EF28 /* Untranslated.strings in Resources */ = {isa = PBXBuildFile; fileRef = D2F7194F440375338F8E2487 /* Untranslated.strings */; }; + CE6F237360875D3D573FD0B2 /* RoomNotificationSettingsProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */; }; CE7148E80F09B7305E026AC6 /* OnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1198B925F4A88DA74083662 /* OnboardingViewModel.swift */; }; CE9530A4CA661E090635C2F2 /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F7FE40EF7490A7E09D7BE6 /* NotificationItemProxy.swift */; }; CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41FABA2B0AEF4389986495 /* LoginMode.swift */; }; @@ -693,6 +698,7 @@ EEAE954289DE813A61656AE0 /* LayoutDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */; }; EEB9C1555C63B93CA9C372C2 /* EmojiPickerScreenHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B5E29E9A22F45534FBD5B58 /* EmojiPickerScreenHeaderView.swift */; }; EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; }; + EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */; }; EF7924005216B8189898F370 /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */; }; F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; @@ -943,6 +949,7 @@ 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = ""; }; 37A243E04B58DC6E41FDCD82 /* EmojiItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItem.swift; sourceTree = ""; }; 37CA26F55123E36B50DB0B3A /* AttributedStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringTests.swift; sourceTree = ""; }; + 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyMock.swift; sourceTree = ""; }; 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreen.swift; sourceTree = ""; }; 39001365B76B89983FDB7AD8 /* EmojiMartJSONLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartJSONLoader.swift; sourceTree = ""; }; 3948D16F021DFDB2CD26EAA8 /* MockBackgroundTaskService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBackgroundTaskService.swift; sourceTree = ""; }; @@ -1010,6 +1017,7 @@ 4F0CB536D1C3CC15AA740CC6 /* AuthenticationServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxyProtocol.swift; sourceTree = ""; }; 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = ""; }; 4FD6E621CC5E6D4830D96D2D /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = ""; }; + 4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyProtocol.swift; sourceTree = ""; }; 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = ""; }; 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineItem.swift; sourceTree = ""; }; 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixUserShareLink.swift; sourceTree = ""; }; @@ -1247,6 +1255,7 @@ AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = ""; }; ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; + AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxy.swift; sourceTree = ""; }; ADB35E2DB4EFE8E6F3959629 /* InviteUsersScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenUITests.swift; sourceTree = ""; }; ADD9E0FFA29EAACFF3AB9732 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = ""; }; ADE6170EFE6A161B0A68AB61 /* ClientMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientMock.swift; sourceTree = ""; }; @@ -1402,10 +1411,12 @@ E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorModalView.swift; sourceTree = ""; }; E51E3D86A84341C3A0CB8A40 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = ""; }; E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; + E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxyProtocol.swift; sourceTree = ""; }; E5C3AACCAA82392D08924496 /* EmojiMartCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartCategory.swift; sourceTree = ""; }; E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerProtocol.swift; sourceTree = ""; }; E6281B199D8A8F0892490C2E /* OnboardingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinator.swift; sourceTree = ""; }; E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; + E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxy.swift; sourceTree = ""; }; E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactory.swift; sourceTree = ""; }; E80F9E9B93B6ECE9A937B1C6 /* FormRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRow.swift; sourceTree = ""; }; E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorStateMachine.swift; sourceTree = ""; }; @@ -1445,6 +1456,7 @@ F506C6ADB1E1DA6638078E11 /* UITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F5311C989EC15B4C2D699025 /* StaticLocationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationScreenViewModel.swift; sourceTree = ""; }; F57C8022B8A871A1DCD1750A /* UserIndicatorToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorToastView.swift; sourceTree = ""; }; + F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxyMock.swift; sourceTree = ""; }; F6D698BFD68B061350553930 /* WaitingDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitingDialog.swift; sourceTree = ""; }; F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = ""; }; F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = ""; }; @@ -1609,6 +1621,7 @@ CA555F7C7CA382ACACF0D82B /* Keychain */, 79E560F5113ED25D172E550C /* Media */, 6DE13A7AE6587B079F4049D7 /* Notification */, + 114DC16B28140F885FD833E2 /* NotificationSettings */, 40E6246F03D1FE377BC5D963 /* Room */, 07900E9BFFD109F91B35B4C5 /* RoomMember */, 82D5AD3EAE3A5C1068A44A88 /* Session */, @@ -1669,6 +1682,17 @@ path = ViewModel; sourceTree = ""; }; + 114DC16B28140F885FD833E2 /* NotificationSettings */ = { + isa = PBXGroup; + children = ( + E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */, + 4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */, + AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */, + E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */, + ); + path = NotificationSettings; + sourceTree = ""; + }; 13ACE3300D6A86770E757FC0 /* View */ = { isa = PBXGroup; children = ( @@ -1834,7 +1858,9 @@ isa = PBXGroup; children = ( 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */, + 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */, + F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */, 1ABDE6F66532CBEB0E016F94 /* RoomProxyMock.swift */, 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */, 7893780A1FD6E3F38B3E9049 /* UserIndicatorControllerMock.swift */, @@ -4403,6 +4429,9 @@ 652ACCF104A8CEF30788963C /* NotificationManager.swift in Sources */, 06D3942496E9E0E655F14D21 /* NotificationManagerProtocol.swift in Sources */, C4C84901ABAC9B17564AB7EB /* NotificationName.swift in Sources */, + B93FA0DA1504B301CAEE141B /* NotificationSettingsProxy.swift in Sources */, + 4BAB8222DBA0B4207D1223E0 /* NotificationSettingsProxyMock.swift in Sources */, + A1BA8D6BABAFA9BAAEAA3FFD /* NotificationSettingsProxyProtocol.swift in Sources */, 6786C4B0936AC84D993B20BF /* NotificationSettingsScreen.swift in Sources */, F6DFA23885980118AD7359C5 /* NotificationSettingsScreenCoordinator.swift in Sources */, D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */, @@ -4467,6 +4496,9 @@ 8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */, F3E2D3F7ACDED65A4E5CD8DE /* RoomMembersListScreenViewModel.swift in Sources */, C4078364FD9FA00EA9D00A15 /* RoomMembersListScreenViewModelProtocol.swift in Sources */, + CE6F237360875D3D573FD0B2 /* RoomNotificationSettingsProxy.swift in Sources */, + 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */, + EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */, 4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */, BD203FC6A7AE7637EA003643 /* RoomProxyMock.swift in Sources */, FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index c692f2a1a..fd93eac79 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -285,7 +285,12 @@ "screen_room_details_edition_error_title" = "Unable to update room"; "screen_room_details_encryption_enabled_subtitle" = "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."; "screen_room_details_encryption_enabled_title" = "Message encryption enabled"; +"screen_room_details_error_loading_notification_settings" = "An error occurred when loading notification settings."; +"screen_room_details_error_muting" = "Failed muting this room, please try again."; +"screen_room_details_error_unmuting" = "Failed unmuting this room, please try again."; "screen_room_details_invite_people_title" = "Invite people"; +"screen_room_details_notification_mode_custom" = "Custom"; +"screen_room_details_notification_mode_default" = "Default"; "screen_room_details_notification_title" = "Notifications"; "screen_room_details_room_name_label" = "Room name"; "screen_room_details_share_room_title" = "Share room"; diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 7118f51aa..d3d01337c 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -364,12 +364,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return } - let params = RoomDetailsScreenCoordinatorParameters(accountUserID: userSession.userID, - navigationStackCoordinator: navigationStackCoordinator, - roomProxy: roomProxy, - mediaProvider: userSession.mediaProvider, - userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy), - userIndicatorController: userIndicatorController) + let params = await RoomDetailsScreenCoordinatorParameters(accountUserID: userSession.userID, + navigationStackCoordinator: navigationStackCoordinator, + roomProxy: roomProxy, + mediaProvider: userSession.mediaProvider, + userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy), + userIndicatorController: userIndicatorController, + notificationSettings: userSession.clientProxy.notificationSettings()) let coordinator = RoomDetailsScreenCoordinator(parameters: params) coordinator.callback = { [weak self] action in switch action { diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index b7ce519be..a282dbda3 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -724,10 +724,20 @@ public enum L10n { public static var screenRoomDetailsEncryptionEnabledSubtitle: String { return L10n.tr("Localizable", "screen_room_details_encryption_enabled_subtitle") } /// Message encryption enabled public static var screenRoomDetailsEncryptionEnabledTitle: String { return L10n.tr("Localizable", "screen_room_details_encryption_enabled_title") } + /// An error occurred when loading notification settings. + public static var screenRoomDetailsErrorLoadingNotificationSettings: String { return L10n.tr("Localizable", "screen_room_details_error_loading_notification_settings") } + /// Failed muting this room, please try again. + public static var screenRoomDetailsErrorMuting: String { return L10n.tr("Localizable", "screen_room_details_error_muting") } + /// Failed unmuting this room, please try again. + public static var screenRoomDetailsErrorUnmuting: String { return L10n.tr("Localizable", "screen_room_details_error_unmuting") } /// Invite people public static var screenRoomDetailsInvitePeopleTitle: String { return L10n.tr("Localizable", "screen_room_details_invite_people_title") } /// Leave room public static var screenRoomDetailsLeaveRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_leave_room_title") } + /// Custom + public static var screenRoomDetailsNotificationModeCustom: String { return L10n.tr("Localizable", "screen_room_details_notification_mode_custom") } + /// Default + public static var screenRoomDetailsNotificationModeDefault: String { return L10n.tr("Localizable", "screen_room_details_notification_mode_default") } /// Notifications public static var screenRoomDetailsNotificationTitle: String { return L10n.tr("Localizable", "screen_room_details_notification_title") } /// People diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index bc4844e69..f2312c0d9 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -313,6 +313,260 @@ class NotificationManagerMock: NotificationManagerProtocol { requestAuthorizationClosure?() } } +class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol { + var callbacks: PassthroughSubject { + get { return underlyingCallbacks } + set(value) { underlyingCallbacks = value } + } + var underlyingCallbacks: PassthroughSubject! + + //MARK: - getNotificationSettings + + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountThrowableError: Error? + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountCallsCount = 0 + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountCalled: Bool { + return getNotificationSettingsRoomIdIsEncryptedActiveMembersCountCallsCount > 0 + } + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReceivedArguments: (roomId: String, isEncrypted: Bool, activeMembersCount: UInt64)? + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReceivedInvocations: [(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64)] = [] + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue: RoomNotificationSettingsProxyProtocol! + var getNotificationSettingsRoomIdIsEncryptedActiveMembersCountClosure: ((String, Bool, UInt64) async throws -> RoomNotificationSettingsProxyProtocol)? + + func getNotificationSettings(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64) async throws -> RoomNotificationSettingsProxyProtocol { + if let error = getNotificationSettingsRoomIdIsEncryptedActiveMembersCountThrowableError { + throw error + } + getNotificationSettingsRoomIdIsEncryptedActiveMembersCountCallsCount += 1 + getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReceivedArguments = (roomId: roomId, isEncrypted: isEncrypted, activeMembersCount: activeMembersCount) + getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReceivedInvocations.append((roomId: roomId, isEncrypted: isEncrypted, activeMembersCount: activeMembersCount)) + if let getNotificationSettingsRoomIdIsEncryptedActiveMembersCountClosure = getNotificationSettingsRoomIdIsEncryptedActiveMembersCountClosure { + return try await getNotificationSettingsRoomIdIsEncryptedActiveMembersCountClosure(roomId, isEncrypted, activeMembersCount) + } else { + return getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue + } + } + //MARK: - setNotificationMode + + var setNotificationModeRoomIdModeThrowableError: Error? + var setNotificationModeRoomIdModeCallsCount = 0 + var setNotificationModeRoomIdModeCalled: Bool { + return setNotificationModeRoomIdModeCallsCount > 0 + } + var setNotificationModeRoomIdModeReceivedArguments: (roomId: String, mode: RoomNotificationMode)? + var setNotificationModeRoomIdModeReceivedInvocations: [(roomId: String, mode: RoomNotificationMode)] = [] + var setNotificationModeRoomIdModeClosure: ((String, RoomNotificationMode) async throws -> Void)? + + func setNotificationMode(roomId: String, mode: RoomNotificationMode) async throws { + if let error = setNotificationModeRoomIdModeThrowableError { + throw error + } + setNotificationModeRoomIdModeCallsCount += 1 + setNotificationModeRoomIdModeReceivedArguments = (roomId: roomId, mode: mode) + setNotificationModeRoomIdModeReceivedInvocations.append((roomId: roomId, mode: mode)) + try await setNotificationModeRoomIdModeClosure?(roomId, mode) + } + //MARK: - getDefaultNotificationRoomMode + + var getDefaultNotificationRoomModeIsEncryptedActiveMembersCountCallsCount = 0 + var getDefaultNotificationRoomModeIsEncryptedActiveMembersCountCalled: Bool { + return getDefaultNotificationRoomModeIsEncryptedActiveMembersCountCallsCount > 0 + } + var getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReceivedArguments: (isEncrypted: Bool, activeMembersCount: UInt64)? + var getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReceivedInvocations: [(isEncrypted: Bool, activeMembersCount: UInt64)] = [] + var getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReturnValue: RoomNotificationMode! + var getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure: ((Bool, UInt64) async -> RoomNotificationMode)? + + func getDefaultNotificationRoomMode(isEncrypted: Bool, activeMembersCount: UInt64) async -> RoomNotificationMode { + getDefaultNotificationRoomModeIsEncryptedActiveMembersCountCallsCount += 1 + getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReceivedArguments = (isEncrypted: isEncrypted, activeMembersCount: activeMembersCount) + getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReceivedInvocations.append((isEncrypted: isEncrypted, activeMembersCount: activeMembersCount)) + if let getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure = getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure { + return await getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure(isEncrypted, activeMembersCount) + } else { + return getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReturnValue + } + } + //MARK: - restoreDefaultNotificationMode + + var restoreDefaultNotificationModeRoomIdThrowableError: Error? + var restoreDefaultNotificationModeRoomIdCallsCount = 0 + var restoreDefaultNotificationModeRoomIdCalled: Bool { + return restoreDefaultNotificationModeRoomIdCallsCount > 0 + } + var restoreDefaultNotificationModeRoomIdReceivedRoomId: String? + var restoreDefaultNotificationModeRoomIdReceivedInvocations: [String] = [] + var restoreDefaultNotificationModeRoomIdClosure: ((String) async throws -> Void)? + + func restoreDefaultNotificationMode(roomId: String) async throws { + if let error = restoreDefaultNotificationModeRoomIdThrowableError { + throw error + } + restoreDefaultNotificationModeRoomIdCallsCount += 1 + restoreDefaultNotificationModeRoomIdReceivedRoomId = roomId + restoreDefaultNotificationModeRoomIdReceivedInvocations.append(roomId) + try await restoreDefaultNotificationModeRoomIdClosure?(roomId) + } + //MARK: - containsKeywordsRules + + var containsKeywordsRulesCallsCount = 0 + var containsKeywordsRulesCalled: Bool { + return containsKeywordsRulesCallsCount > 0 + } + var containsKeywordsRulesReturnValue: Bool! + var containsKeywordsRulesClosure: (() async -> Bool)? + + func containsKeywordsRules() async -> Bool { + containsKeywordsRulesCallsCount += 1 + if let containsKeywordsRulesClosure = containsKeywordsRulesClosure { + return await containsKeywordsRulesClosure() + } else { + return containsKeywordsRulesReturnValue + } + } + //MARK: - unmuteRoom + + var unmuteRoomRoomIdIsEncryptedActiveMembersCountThrowableError: Error? + var unmuteRoomRoomIdIsEncryptedActiveMembersCountCallsCount = 0 + var unmuteRoomRoomIdIsEncryptedActiveMembersCountCalled: Bool { + return unmuteRoomRoomIdIsEncryptedActiveMembersCountCallsCount > 0 + } + var unmuteRoomRoomIdIsEncryptedActiveMembersCountReceivedArguments: (roomId: String, isEncrypted: Bool, activeMembersCount: UInt64)? + var unmuteRoomRoomIdIsEncryptedActiveMembersCountReceivedInvocations: [(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64)] = [] + var unmuteRoomRoomIdIsEncryptedActiveMembersCountClosure: ((String, Bool, UInt64) async throws -> Void)? + + func unmuteRoom(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64) async throws { + if let error = unmuteRoomRoomIdIsEncryptedActiveMembersCountThrowableError { + throw error + } + unmuteRoomRoomIdIsEncryptedActiveMembersCountCallsCount += 1 + unmuteRoomRoomIdIsEncryptedActiveMembersCountReceivedArguments = (roomId: roomId, isEncrypted: isEncrypted, activeMembersCount: activeMembersCount) + unmuteRoomRoomIdIsEncryptedActiveMembersCountReceivedInvocations.append((roomId: roomId, isEncrypted: isEncrypted, activeMembersCount: activeMembersCount)) + try await unmuteRoomRoomIdIsEncryptedActiveMembersCountClosure?(roomId, isEncrypted, activeMembersCount) + } + //MARK: - isRoomMentionEnabled + + var isRoomMentionEnabledThrowableError: Error? + var isRoomMentionEnabledCallsCount = 0 + var isRoomMentionEnabledCalled: Bool { + return isRoomMentionEnabledCallsCount > 0 + } + var isRoomMentionEnabledReturnValue: Bool! + var isRoomMentionEnabledClosure: (() async throws -> Bool)? + + func isRoomMentionEnabled() async throws -> Bool { + if let error = isRoomMentionEnabledThrowableError { + throw error + } + isRoomMentionEnabledCallsCount += 1 + if let isRoomMentionEnabledClosure = isRoomMentionEnabledClosure { + return try await isRoomMentionEnabledClosure() + } else { + return isRoomMentionEnabledReturnValue + } + } + //MARK: - setRoomMentionEnabled + + var setRoomMentionEnabledEnabledThrowableError: Error? + var setRoomMentionEnabledEnabledCallsCount = 0 + var setRoomMentionEnabledEnabledCalled: Bool { + return setRoomMentionEnabledEnabledCallsCount > 0 + } + var setRoomMentionEnabledEnabledReceivedEnabled: Bool? + var setRoomMentionEnabledEnabledReceivedInvocations: [Bool] = [] + var setRoomMentionEnabledEnabledClosure: ((Bool) async throws -> Void)? + + func setRoomMentionEnabled(enabled: Bool) async throws { + if let error = setRoomMentionEnabledEnabledThrowableError { + throw error + } + setRoomMentionEnabledEnabledCallsCount += 1 + setRoomMentionEnabledEnabledReceivedEnabled = enabled + setRoomMentionEnabledEnabledReceivedInvocations.append(enabled) + try await setRoomMentionEnabledEnabledClosure?(enabled) + } + //MARK: - isUserMentionEnabled + + var isUserMentionEnabledThrowableError: Error? + var isUserMentionEnabledCallsCount = 0 + var isUserMentionEnabledCalled: Bool { + return isUserMentionEnabledCallsCount > 0 + } + var isUserMentionEnabledReturnValue: Bool! + var isUserMentionEnabledClosure: (() async throws -> Bool)? + + func isUserMentionEnabled() async throws -> Bool { + if let error = isUserMentionEnabledThrowableError { + throw error + } + isUserMentionEnabledCallsCount += 1 + if let isUserMentionEnabledClosure = isUserMentionEnabledClosure { + return try await isUserMentionEnabledClosure() + } else { + return isUserMentionEnabledReturnValue + } + } + //MARK: - setUserMentionEnabled + + var setUserMentionEnabledEnabledThrowableError: Error? + var setUserMentionEnabledEnabledCallsCount = 0 + var setUserMentionEnabledEnabledCalled: Bool { + return setUserMentionEnabledEnabledCallsCount > 0 + } + var setUserMentionEnabledEnabledReceivedEnabled: Bool? + var setUserMentionEnabledEnabledReceivedInvocations: [Bool] = [] + var setUserMentionEnabledEnabledClosure: ((Bool) async throws -> Void)? + + func setUserMentionEnabled(enabled: Bool) async throws { + if let error = setUserMentionEnabledEnabledThrowableError { + throw error + } + setUserMentionEnabledEnabledCallsCount += 1 + setUserMentionEnabledEnabledReceivedEnabled = enabled + setUserMentionEnabledEnabledReceivedInvocations.append(enabled) + try await setUserMentionEnabledEnabledClosure?(enabled) + } + //MARK: - isCallEnabled + + var isCallEnabledThrowableError: Error? + var isCallEnabledCallsCount = 0 + var isCallEnabledCalled: Bool { + return isCallEnabledCallsCount > 0 + } + var isCallEnabledReturnValue: Bool! + var isCallEnabledClosure: (() async throws -> Bool)? + + func isCallEnabled() async throws -> Bool { + if let error = isCallEnabledThrowableError { + throw error + } + isCallEnabledCallsCount += 1 + if let isCallEnabledClosure = isCallEnabledClosure { + return try await isCallEnabledClosure() + } else { + return isCallEnabledReturnValue + } + } + //MARK: - setCallEnabled + + var setCallEnabledEnabledThrowableError: Error? + var setCallEnabledEnabledCallsCount = 0 + var setCallEnabledEnabledCalled: Bool { + return setCallEnabledEnabledCallsCount > 0 + } + var setCallEnabledEnabledReceivedEnabled: Bool? + var setCallEnabledEnabledReceivedInvocations: [Bool] = [] + var setCallEnabledEnabledClosure: ((Bool) async throws -> Void)? + + func setCallEnabled(enabled: Bool) async throws { + if let error = setCallEnabledEnabledThrowableError { + throw error + } + setCallEnabledEnabledCallsCount += 1 + setCallEnabledEnabledReceivedEnabled = enabled + setCallEnabledEnabledReceivedInvocations.append(enabled) + try await setCallEnabledEnabledClosure?(enabled) + } +} class RoomMemberProxyMock: RoomMemberProxyProtocol { var userID: String { get { return underlyingUserID } @@ -413,6 +667,19 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol { } } } +class RoomNotificationSettingsProxyMock: RoomNotificationSettingsProxyProtocol { + var mode: RoomNotificationMode { + get { return underlyingMode } + set(value) { underlyingMode = value } + } + var underlyingMode: RoomNotificationMode! + var isDefault: Bool { + get { return underlyingIsDefault } + set(value) { underlyingIsDefault = value } + } + var underlyingIsDefault: Bool! + +} class RoomProxyMock: RoomProxyProtocol { var id: String { get { return underlyingId } diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index b34a789ac..795a343d8 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -274,6 +274,7 @@ class SDKClientMock: SDKClientProtocol { return getNotificationSettingsReturnValue } } + //MARK: - `getProfile` public var getProfileUserIdThrowableError: Error? diff --git a/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift b/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift new file mode 100644 index 000000000..45bc648a7 --- /dev/null +++ b/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift @@ -0,0 +1,35 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import MatrixRustSDK + +struct NotificationSettingsProxyMockConfiguration { + var callback = PassthroughSubject() + var defaultRoomMode: RoomNotificationMode = .mentionsAndKeywordsOnly + var roomMode = RoomNotificationSettingsProxyMock(with: RoomNotificationSettingsProxyMockConfiguration(mode: .allMessages, isDefault: true)) +} + +extension NotificationSettingsProxyMock { + convenience init(with configuration: NotificationSettingsProxyMockConfiguration) { + self.init() + + callbacks = configuration.callback + getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = configuration.roomMode + getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReturnValue = configuration.defaultRoomMode + } +} diff --git a/ElementX/Sources/Mocks/RoomNotificationSettingsProxyMock.swift b/ElementX/Sources/Mocks/RoomNotificationSettingsProxyMock.swift new file mode 100644 index 000000000..ee4577ef5 --- /dev/null +++ b/ElementX/Sources/Mocks/RoomNotificationSettingsProxyMock.swift @@ -0,0 +1,32 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixRustSDK + +struct RoomNotificationSettingsProxyMockConfiguration { + var mode: RoomNotificationMode = .allMessages + var isDefault = true +} + +extension RoomNotificationSettingsProxyMock { + convenience init(with configuration: RoomNotificationSettingsProxyMockConfiguration) { + self.init() + + isDefault = configuration.isDefault + mode = configuration.mode + } +} diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index e2637d10b..5a660a6d5 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -103,6 +103,7 @@ struct A11yIdentifiers { let dmAvatar = "room_details-dm_avatar" let people = "room_details-people" let invite = "room_details-invite" + let notifications = "room_details-notifications" } struct RoomMemberDetailsScreen { diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift index 21224d0c9..a277b0203 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift @@ -24,6 +24,7 @@ struct RoomDetailsScreenCoordinatorParameters { let mediaProvider: MediaProviderProtocol let userDiscoveryService: UserDiscoveryServiceProtocol let userIndicatorController: UserIndicatorControllerProtocol + let notificationSettings: NotificationSettingsProxyProtocol } enum RoomDetailsScreenCoordinatorAction { @@ -47,7 +48,9 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { viewModel = RoomDetailsScreenViewModel(accountUserID: parameters.accountUserID, roomProxy: parameters.roomProxy, mediaProvider: parameters.mediaProvider, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + notificationSettingsProxy: parameters.notificationSettings, + appSettings: ServiceLocator.shared.settings) } // MARK: - Public @@ -65,6 +68,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { self.callback?(.leftRoom) case .requestEditDetailsPresentation(let accountOwner): self.presentRoomDetailsEditScreen(accountOwner: accountOwner) + case .requestNotificationSettingsPresentation: + self.presentNotificationSettings() } } } @@ -182,6 +187,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { message: L10n.commonUnableToInviteMessage) } } + + private func presentNotificationSettings() { } } private extension Result { diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift index 9f4d60a0b..99a3918d7 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift @@ -15,6 +15,7 @@ // import Foundation +import SwiftUI import UIKit // MARK: - Coordinator @@ -22,6 +23,7 @@ import UIKit // MARK: View model enum RoomDetailsScreenViewModelAction { + case requestNotificationSettingsPresentation case requestMemberDetailsPresentation case requestInvitePeoplePresentation case leftRoom @@ -46,6 +48,8 @@ struct RoomDetailsScreenViewState: BindableState { var canEditRoomName = false var canEditRoomTopic = false var canEditRoomAvatar = false + let showNotificationSettings: Bool + var notificationSettingsState: RoomDetailsNotificationSettingsState = .loading var canEdit: Bool { !isDirect && (canEditRoomName || canEditRoomTopic || canEditRoomAvatar) @@ -58,6 +62,36 @@ struct RoomDetailsScreenViewState: BindableState { var bindings: RoomDetailsScreenViewStateBindings var dmRecipient: RoomMemberDetails? + + var shortcuts: [RoomDetailsScreenViewShortcut] { + var shortcuts: [RoomDetailsScreenViewShortcut] = [] + if showNotificationSettings { + shortcuts.append(.mute) + } + if let permalink = dmRecipient?.permalink { + shortcuts.append(.share(link: permalink)) + } else if let permalink { + shortcuts.append(.share(link: permalink)) + } + return shortcuts + } + + var isProcessingMuteToggleAction = false + + var areNotificationsMuted: Bool { + if case .loaded(let settings) = notificationSettingsState { + return settings.mode == .mute + } + return false + } + + var notificationShortcutButtonTitle: String { + areNotificationsMuted ? L10n.commonUnmute : L10n.commonMute + } + + var notificationShortcutButtonImage: Image { + areNotificationsMuted ? Image(systemName: "bell.slash.fill") : Image(systemName: "bell") + } } struct RoomDetailsScreenViewStateBindings { @@ -138,11 +172,64 @@ enum RoomDetailsScreenViewAction { case confirmLeave case ignoreConfirmed case unignoreConfirmed + case processTapNotifications + case processToogleMuteNotifications +} + +enum RoomDetailsScreenViewShortcut { + case share(link: URL) + case mute +} + +extension RoomDetailsScreenViewShortcut: Hashable { } + +enum RoomDetailsNotificationSettingsState { + case loading + case loaded(settings: RoomNotificationSettingsProxyProtocol) + case error +} + +extension RoomDetailsNotificationSettingsState { + var label: String { + switch self { + case .loading: + return L10n.commonLoading + case .loaded(let settings): + if settings.isDefault { + return L10n.screenRoomDetailsNotificationModeDefault + } else { + return L10n.screenRoomDetailsNotificationModeCustom + } + case .error: + return L10n.commonError + } + } + + var isLoading: Bool { + if case .loading = self { + return true + } + return false + } + + var isLoaded: Bool { + if case .loaded = self { + return true + } + return false + } + + var isError: Bool { + if case .error = self { + return true + } + return false + } } enum RoomDetailsScreenErrorType: Hashable { /// A specific error message shown in an alert. - case alert(String) + case alert /// Leaving room has failed.. case unknown } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index ce9cc0c27..c2b3fd09a 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -23,6 +23,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr private let accountUserID: String private let roomProxy: RoomProxyProtocol private let userIndicatorController: UserIndicatorControllerProtocol + private let notificationSettingsProxy: NotificationSettingsProxyProtocol private var accountOwner: RoomMemberProxyProtocol? { didSet { updatePowerLevelPermissions() } @@ -35,10 +36,13 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr init(accountUserID: String, roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + notificationSettingsProxy: NotificationSettingsProxyProtocol, + appSettings: AppSettings) { self.accountUserID = accountUserID self.roomProxy = roomProxy self.userIndicatorController = userIndicatorController + self.notificationSettingsProxy = notificationSettingsProxy super.init(initialViewState: .init(roomId: roomProxy.id, canonicalAlias: roomProxy.canonicalAlias, @@ -49,11 +53,16 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr topic: roomProxy.topic, avatarURL: roomProxy.avatarURL, joinedMembersCount: roomProxy.joinedMembersCount, + showNotificationSettings: appSettings.notificationSettingsEnabled, + notificationSettingsState: .loading, bindings: .init()), imageProvider: mediaProvider) setupRoomSubscription() fetchMembers() + + setupNotificationSettingsSubscription() + fetchNotificationSettings() } // MARK: - Public @@ -87,6 +96,14 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr Task { await ignore() } case .unignoreConfirmed: Task { await unignore() } + case .processTapNotifications: + if state.notificationSettingsState.isError { + fetchNotificationSettings() + } else { + callback?(.requestNotificationSettingsPresentation) + } + case .processToogleMuteNotifications: + Task { await toggleMuteNotifications() } } } @@ -146,6 +163,66 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr state.canEditRoomAvatar = accountOwner?.canSendStateEvent(type: .roomAvatar) ?? false } + private func setupNotificationSettingsSubscription() { + notificationSettingsProxy.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + guard let self else { return } + + switch callback { + case .settingsDidChange: + self.fetchNotificationSettings() + } + } + .store(in: &cancellables) + } + + private func fetchNotificationSettings() { + Task { + await fetchRoomNotificationSettings() + } + } + + private func fetchRoomNotificationSettings() async { + do { + let notificationMode = try await notificationSettingsProxy.getNotificationSettings(roomId: roomProxy.id, + isEncrypted: roomProxy.isEncrypted, + activeMembersCount: UInt64(roomProxy.activeMembersCount)) + state.notificationSettingsState = .loaded(settings: notificationMode) + } catch { + state.notificationSettingsState = .error + state.bindings.alertInfo = AlertInfo(id: .alert, + title: L10n.commonError, + message: L10n.screenRoomDetailsErrorLoadingNotificationSettings) + } + } + + private func toggleMuteNotifications() async { + guard case .loaded(let notificationMode) = state.notificationSettingsState else { return } + state.isProcessingMuteToggleAction = true + switch notificationMode.mode { + case .mute: + do { + try await notificationSettingsProxy.unmuteRoom(roomId: roomProxy.id, + isEncrypted: roomProxy.isEncrypted, + activeMembersCount: UInt64(roomProxy.activeMembersCount)) + } catch { + state.bindings.alertInfo = AlertInfo(id: .alert, + title: L10n.commonError, + message: L10n.screenRoomDetailsErrorUnmuting) + } + default: + do { + try await notificationSettingsProxy.setNotificationMode(roomId: roomProxy.id, mode: .mute) + } catch { + state.bindings.alertInfo = AlertInfo(id: .alert, + title: L10n.commonError, + message: L10n.screenRoomDetailsErrorMuting) + } + } + state.isProcessingMuteToggleAction = false + } + private static let leaveRoomLoadingID = "LeaveRoomLoading" private func leaveRoom() async { diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift index 68bc11eca..328313ed7 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift @@ -30,6 +30,10 @@ struct RoomDetailsScreen: View { } topicSection + + if context.viewState.showNotificationSettings { + notificationSection + } if context.viewState.dmRecipient == nil { aboutSection @@ -73,14 +77,8 @@ struct RoomDetailsScreen: View { avatarSize: .room(on: .details), imageProvider: context.imageProvider, subtitle: context.viewState.canonicalAlias) { - if let permalink = context.viewState.permalink { - HStack(spacing: 32) { - ShareLink(item: permalink) { - Image(systemName: "square.and.arrow.up") - } - .buttonStyle(FormActionButtonStyle(title: L10n.actionShare)) - } - .padding(.top, 32) + if !context.viewState.shortcuts.isEmpty { + headerSectionShortcuts } } .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.avatar) @@ -94,17 +92,29 @@ struct RoomDetailsScreen: View { avatarSize: .user(on: .memberDetails), imageProvider: context.imageProvider, subtitle: recipient.id) { - if let permalink = recipient.permalink { - HStack(spacing: 32) { + if !context.viewState.shortcuts.isEmpty { + headerSectionShortcuts + } + } + .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.dmAvatar) + } + + @ViewBuilder + private var headerSectionShortcuts: some View { + HStack(spacing: 32) { + ForEach(context.viewState.shortcuts, id: \.self) { shortcut in + switch shortcut { + case .mute: + toggleMuteButton + case .share(let permalink): ShareLink(item: permalink) { Image(systemName: "square.and.arrow.up") } .buttonStyle(FormActionButtonStyle(title: L10n.actionShare)) } - .padding(.top, 32) } } - .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.dmAvatar) + .padding(.top, 32) } @ViewBuilder @@ -163,6 +173,49 @@ struct RoomDetailsScreen: View { .compoundFormSection() .foregroundColor(.compound.textPrimary) } + + @ViewBuilder + private var notificationSection: some View { + Section { + Button { + context.send(viewAction: .processTapNotifications) + } label: { + LabeledContent { + if context.viewState.notificationSettingsState.isLoading { + ProgressView() + } else if context.viewState.notificationSettingsState.isError { + Image(systemName: "exclamationmark.circle") + } else { + Text(context.viewState.notificationSettingsState.label) + .foregroundColor(.compound.textSecondary) + .font(.compound.bodyLG) + } + } label: { + Label(L10n.screenRoomDetailsNotificationTitle, systemImage: "bell") + } + } + .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.notifications) + } + .listRowSeparatorTint(.compound.borderDisabled) + .buttonStyle(FormButtonStyle(accessory: context.viewState.notificationSettingsState.isLoaded ? .navigationLink : nil)) + .foregroundColor(.compound.textPrimary) + .disabled(context.viewState.notificationSettingsState.isLoading) + } + + @ViewBuilder + private var toggleMuteButton: some View { + Button { + context.send(viewAction: .processToogleMuteNotifications) + } label: { + if context.viewState.isProcessingMuteToggleAction { + ProgressView() + } else { + context.viewState.notificationShortcutButtonImage + } + } + .buttonStyle(FormActionButtonStyle(title: context.viewState.notificationShortcutButtonTitle)) + .disabled(context.viewState.isProcessingMuteToggleAction) + } @ViewBuilder private var securitySection: some View { @@ -256,10 +309,17 @@ struct RoomDetailsScreen_Previews: PreviewProvider { canonicalAlias: "#alias:domain.com", members: members)) + var notificationSettingsProxyMockConfiguration = NotificationSettingsProxyMockConfiguration() + notificationSettingsProxyMockConfiguration.roomMode.isDefault = false + let notificationSettingsProxy = NotificationSettingsProxyMock(with: notificationSettingsProxyMockConfiguration) + let appSettings = AppSettings() + return RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxy, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: notificationSettingsProxy, + appSettings: appSettings) }() static let dmRoomViewModel = { @@ -274,11 +334,15 @@ struct RoomDetailsScreen_Previews: PreviewProvider { isEncrypted: true, canonicalAlias: "#alias:domain.com", members: members)) + let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init()) + let appSettings = AppSettings() return RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxy, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: notificationSettingsProxy, + appSettings: appSettings) }() static var previews: some View { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 49346a659..9dd78eb45 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -357,6 +357,13 @@ class ClientProxy: ClientProxyProtocol { } } + func notificationSettings() async -> NotificationSettingsProxyProtocol { + await Task.dispatch(on: clientQueue) { + NotificationSettingsProxy(notificationSettings: self.client.getNotificationSettings(), + backgroundTaskService: self.backgroundTaskService) + } + } + // MARK: Private private func restartSync(delay: Duration = .zero) { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 8ccebcddd..fda903e73 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -113,4 +113,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func searchUsers(searchTerm: String, limit: UInt) async -> Result func profile(for userID: String) async -> Result + + func notificationSettings() async -> NotificationSettingsProxyProtocol } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 5064adf4b..9226e4287 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -140,4 +140,13 @@ class MockClientProxy: ClientProxyProtocol { getProfileCalled = true return getProfileResult } + + var notificationSettingsResult: NotificationSettingsProxyProtocol? + func notificationSettings() -> NotificationSettingsProxyProtocol { + if let notificationSettingsResult { + return notificationSettingsResult + } else { + return NotificationSettingsProxyMock(with: .init()) + } + } } diff --git a/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift b/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift new file mode 100644 index 000000000..af4b1d50e --- /dev/null +++ b/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxy.swift @@ -0,0 +1,122 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import MatrixRustSDK + +private final class WeakNotificationSettingsProxy: NotificationSettingsDelegate { + private weak var proxy: NotificationSettingsProxy? + + init(proxy: NotificationSettingsProxy) { + self.proxy = proxy + } + + // MARK: - NotificationSettingsDelegate + + func settingsDidChange() { + Task { + await proxy?.settingsDidChange() + } + } +} + +final class NotificationSettingsProxy: NotificationSettingsProxyProtocol { + private(set) var notificationSettings: MatrixRustSDK.NotificationSettingsProtocol + private let backgroundTaskService: BackgroundTaskServiceProtocol? + + let callbacks = PassthroughSubject() + + init(notificationSettings: MatrixRustSDK.NotificationSettingsProtocol, backgroundTaskService: BackgroundTaskServiceProtocol?) { + self.notificationSettings = notificationSettings + self.backgroundTaskService = backgroundTaskService + notificationSettings.setDelegate(delegate: WeakNotificationSettingsProxy(proxy: self)) + } + + func getNotificationSettings(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64) async throws -> RoomNotificationSettingsProxyProtocol { + let roomMotificationSettings = try await notificationSettings.getRoomNotificationSettings(roomId: roomId, isEncrypted: isEncrypted, activeMembersCount: activeMembersCount) + return RoomNotificationSettingsProxy(roomNotificationSettings: roomMotificationSettings) + } + + func setNotificationMode(roomId: String, mode: RoomNotificationMode) async throws { + let backgroundTask = await backgroundTaskService?.startBackgroundTask(withName: "setNotificationMode") + defer { backgroundTask?.stop() } + + try await notificationSettings.setRoomNotificationMode(roomId: roomId, mode: mode) + } + + func getDefaultNotificationRoomMode(isEncrypted: Bool, activeMembersCount: UInt64) async -> RoomNotificationMode { + await notificationSettings.getDefaultRoomNotificationMode(isEncrypted: isEncrypted, activeMembersCount: activeMembersCount) + } + + func restoreDefaultNotificationMode(roomId: String) async throws { + let backgroundTask = await backgroundTaskService?.startBackgroundTask(withName: "restoreDefaultNotificationMode") + defer { backgroundTask?.stop() } + + try await notificationSettings.restoreDefaultRoomNotificationMode(roomId: roomId) + } + + func containsKeywordsRules() async -> Bool { + await notificationSettings.containsKeywordsRules() + } + + func unmuteRoom(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64) async throws { + let backgroundTask = await backgroundTaskService?.startBackgroundTask(withName: "unmuteRoom") + defer { backgroundTask?.stop() } + + try await notificationSettings.unmuteRoom(roomId: roomId, isEncrypted: isEncrypted, membersCount: activeMembersCount) + } + + func isRoomMentionEnabled() async throws -> Bool { + try await notificationSettings.isRoomMentionEnabled() + } + + func setRoomMentionEnabled(enabled: Bool) async throws { + let backgroundTask = await backgroundTaskService?.startBackgroundTask(withName: "setRoomMentionEnabled") + defer { backgroundTask?.stop() } + + try await notificationSettings.setRoomMentionEnabled(enabled: enabled) + } + + func isUserMentionEnabled() async throws -> Bool { + try await notificationSettings.isUserMentionEnabled() + } + + func setUserMentionEnabled(enabled: Bool) async throws { + let backgroundTask = await backgroundTaskService?.startBackgroundTask(withName: "setUserMentionEnabled") + defer { backgroundTask?.stop() } + + try await notificationSettings.setUserMentionEnabled(enabled: enabled) + } + + func isCallEnabled() async throws -> Bool { + try await notificationSettings.isCallEnabled() + } + + func setCallEnabled(enabled: Bool) async throws { + let backgroundTask = await backgroundTaskService?.startBackgroundTask(withName: "setCallEnabled") + defer { backgroundTask?.stop() } + + try await notificationSettings.setCallEnabled(enabled: enabled) + } + + // MARK: - Private + + @MainActor + func settingsDidChange() { + callbacks.send(.settingsDidChange) + } +} diff --git a/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxyProtocol.swift b/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxyProtocol.swift new file mode 100644 index 000000000..f08666866 --- /dev/null +++ b/ElementX/Sources/Services/NotificationSettings/NotificationSettingsProxyProtocol.swift @@ -0,0 +1,41 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import MatrixRustSDK + +enum NotificationSettingsProxyCallback { + case settingsDidChange +} + +// sourcery: AutoMockable +protocol NotificationSettingsProxyProtocol { + var callbacks: PassthroughSubject { get } + + func getNotificationSettings(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64) async throws -> RoomNotificationSettingsProxyProtocol + func setNotificationMode(roomId: String, mode: RoomNotificationMode) async throws + func getDefaultNotificationRoomMode(isEncrypted: Bool, activeMembersCount: UInt64) async -> RoomNotificationMode + func restoreDefaultNotificationMode(roomId: String) async throws + func containsKeywordsRules() async -> Bool + func unmuteRoom(roomId: String, isEncrypted: Bool, activeMembersCount: UInt64) async throws + func isRoomMentionEnabled() async throws -> Bool + func setRoomMentionEnabled(enabled: Bool) async throws + func isUserMentionEnabled() async throws -> Bool + func setUserMentionEnabled(enabled: Bool) async throws + func isCallEnabled() async throws -> Bool + func setCallEnabled(enabled: Bool) async throws +} diff --git a/ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxy.swift b/ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxy.swift new file mode 100644 index 000000000..507291821 --- /dev/null +++ b/ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxy.swift @@ -0,0 +1,34 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixRustSDK + +struct RoomNotificationSettingsProxy: RoomNotificationSettingsProxyProtocol { + private let roomNotificationSettings: RoomNotificationSettings + + var mode: RoomNotificationMode { + roomNotificationSettings.mode + } + + var isDefault: Bool { + roomNotificationSettings.isDefault + } + + init(roomNotificationSettings: RoomNotificationSettings) { + self.roomNotificationSettings = roomNotificationSettings + } +} diff --git a/ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxyProtocol.swift b/ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxyProtocol.swift new file mode 100644 index 000000000..79e4b6a3e --- /dev/null +++ b/ElementX/Sources/Services/NotificationSettings/RoomNotificationSettingsProxyProtocol.swift @@ -0,0 +1,24 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixRustSDK + +// sourcery: AutoMockable +protocol RoomNotificationSettingsProxyProtocol { + var mode: RoomNotificationMode { get } + var isDefault: Bool { get } +} diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 52acea80f..5b864863c 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -360,7 +360,8 @@ class MockScreen: Identifiable { roomProxy: roomProxy, mediaProvider: MockMediaProvider(), userDiscoveryService: UserDiscoveryServiceMock(), - userIndicatorController: ServiceLocator.shared.userIndicatorController)) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettings: NotificationSettingsProxyMock(with: .init()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomDetailsScreenWithRoomAvatar: @@ -380,7 +381,8 @@ class MockScreen: Identifiable { roomProxy: roomProxy, mediaProvider: MockMediaProvider(), userDiscoveryService: UserDiscoveryServiceMock(), - userIndicatorController: ServiceLocator.shared.userIndicatorController)) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettings: NotificationSettingsProxyMock(with: .init()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomDetailsScreenWithEmptyTopic: @@ -402,7 +404,8 @@ class MockScreen: Identifiable { roomProxy: roomProxy, mediaProvider: MockMediaProvider(), userDiscoveryService: UserDiscoveryServiceMock(), - userIndicatorController: ServiceLocator.shared.userIndicatorController)) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettings: NotificationSettingsProxyMock(with: .init()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomDetailsScreenWithInvite: @@ -420,7 +423,8 @@ class MockScreen: Identifiable { roomProxy: roomProxy, mediaProvider: MockMediaProvider(), userDiscoveryService: UserDiscoveryServiceMock(), - userIndicatorController: ServiceLocator.shared.userIndicatorController)) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettings: NotificationSettingsProxyMock(with: .init()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomDetailsScreenDmDetails: @@ -439,7 +443,8 @@ class MockScreen: Identifiable { roomProxy: roomProxy, mediaProvider: MockMediaProvider(), userDiscoveryService: UserDiscoveryServiceMock(), - userIndicatorController: ServiceLocator.shared.userIndicatorController)) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettings: NotificationSettingsProxyMock(with: .init()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomEditDetails, .roomEditDetailsReadOnly: diff --git a/UnitTests/Sources/RoomDetailsViewModelTests.swift b/UnitTests/Sources/RoomDetailsViewModelTests.swift index 6c4a5f069..76c5d7581 100644 --- a/UnitTests/Sources/RoomDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsViewModelTests.swift @@ -15,6 +15,7 @@ // import MatrixRustSDK +import SwiftUI import XCTest @testable import ElementX @@ -23,14 +24,18 @@ import XCTest class RoomDetailsScreenViewModelTests: XCTestCase { var viewModel: RoomDetailsScreenViewModel! var roomProxyMock: RoomProxyMock! + var notificationSettingsProxyMock: NotificationSettingsProxyMock! var context: RoomDetailsScreenViewModelType.Context { viewModel.context } override func setUp() { roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", joinedMembersCount: 0)) + notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: notificationSettingsProxyMock, + appSettings: AppSettings()) AppSettings.reset() } @@ -41,7 +46,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) let deferred = deferFulfillment(context.$viewState.collect(2).first()) context.send(viewAction: .processTapLeave) let states = try await deferred.fulfill() @@ -57,7 +64,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) let deferred = deferFulfillment(context.$viewState.collect(2).first()) context.send(viewAction: .processTapLeave) let states = try await deferred.fulfill() @@ -113,7 +122,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) await context.nextViewState() XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient)) } @@ -129,7 +140,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) await context.nextViewState() XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient)) @@ -154,7 +167,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) await context.nextViewState() XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient)) @@ -180,7 +195,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) await context.nextViewState() XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient)) @@ -205,7 +222,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) await context.nextViewState() XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient)) @@ -230,7 +249,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() @@ -243,7 +264,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() @@ -271,7 +294,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() @@ -288,7 +313,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() @@ -305,7 +332,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() @@ -322,7 +351,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() @@ -338,10 +369,191 @@ class RoomDetailsScreenViewModelTests: XCTestCase { viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()), + appSettings: AppSettings()) _ = await context.$viewState.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main).values.first() XCTAssertFalse(context.viewState.canEdit) } + + func testNotificationLoadingSettingsFailure() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountThrowableError = NotificationSettingsError.Generic(message: "error") + viewModel = RoomDetailsScreenViewModel(accountUserID: "@owner:somewhere.com", + roomProxy: roomProxyMock, + mediaProvider: MockMediaProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + notificationSettingsProxy: notificationSettingsProxyMock, + appSettings: AppSettings()) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .filter(\.isError) + .first()) + try await deferred.fulfill() + + let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert, + title: L10n.commonError, + message: L10n.screenRoomDetailsErrorLoadingNotificationSettings) + XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) + XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) + XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + } + + func testNotificationDefaultMode() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: true)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.notificationSettingsState.label, "Default") + } + + func testNotificationCustomMode() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.notificationSettingsState.label, "Custom") + } + + func testNotificationRoomMuted() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mute, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute) + XCTAssertEqual(context.viewState.notificationShortcutButtonImage, Image(systemName: "bell.slash.fill")) + } + + func testNotificationRoomNotMuted() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mentionsAndKeywordsOnly, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute) + XCTAssertEqual(context.viewState.notificationShortcutButtonImage, Image(systemName: "bell")) + } + + func testUnmuteTappedFailure() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mute, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute) + + let expectation = expectation(description: #function) + notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedActiveMembersCountClosure = { _, _, _ in + defer { + expectation.fulfill() + } + throw NotificationSettingsError.Generic(message: "unmute error") + } + context.send(viewAction: .processToogleMuteNotifications) + await fulfillment(of: [expectation]) + + XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) + XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonUnmute) + + let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert, + title: L10n.commonError, + message: L10n.screenRoomDetailsErrorUnmuting) + + XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) + XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) + XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + } + + func testMuteTappedFailure() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute) + + let expectation = expectation(description: #function) + notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { _, _ in + defer { + expectation.fulfill() + } + throw NotificationSettingsError.Generic(message: "mute error") + } + context.send(viewAction: .processToogleMuteNotifications) + await fulfillment(of: [expectation]) + + XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) + XCTAssertEqual(context.viewState.notificationShortcutButtonTitle, L10n.commonMute) + + let expectedAlertInfo = AlertInfo(id: RoomDetailsScreenErrorType.alert, + title: L10n.commonError, + message: L10n.screenRoomDetailsErrorMuting) + + XCTAssertEqual(context.viewState.bindings.alertInfo?.id, expectedAlertInfo.id) + XCTAssertEqual(context.viewState.bindings.alertInfo?.title, expectedAlertInfo.title) + XCTAssertEqual(context.viewState.bindings.alertInfo?.message, expectedAlertInfo.message) + } + + func testMuteTapped() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + let expectation = expectation(description: #function) + notificationSettingsProxyMock.setNotificationModeRoomIdModeClosure = { [weak notificationSettingsProxyMock] _, mode in + notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: mode, isDefault: false)) + expectation.fulfill() + } + context.send(viewAction: .processToogleMuteNotifications) + await fulfillment(of: [expectation]) + + XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) + + do { + let deferred = deferFulfillment(context.$viewState.first()) + notificationSettingsProxyMock.callbacks.send(.settingsDidChange) + try await deferred.fulfill() + } + + if case .loaded(let newNotificationSettingsState) = viewModel.state.notificationSettingsState { + XCTAssertFalse(newNotificationSettingsState.isDefault) + XCTAssertEqual(newNotificationSettingsState.mode, .mute) + } else { + XCTFail("invalid state") + } + } + + func testUnmuteTapped() async throws { + notificationSettingsProxyMock.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .mute, isDefault: false)) + let deferred = deferFulfillment(context.$viewState.map(\.notificationSettingsState) + .first(where: \.isLoaded)) + try await deferred.fulfill() + + let expectation = expectation(description: #function) + notificationSettingsProxyMock.unmuteRoomRoomIdIsEncryptedActiveMembersCountClosure = { [weak notificationSettingsProxyMock] _, _, _ in + notificationSettingsProxyMock?.getNotificationSettingsRoomIdIsEncryptedActiveMembersCountReturnValue = RoomNotificationSettingsProxyMock(with: .init(mode: .allMessages, isDefault: false)) + expectation.fulfill() + } + context.send(viewAction: .processToogleMuteNotifications) + await fulfillment(of: [expectation]) + + XCTAssertFalse(context.viewState.isProcessingMuteToggleAction) + + do { + let deferred = deferFulfillment(context.$viewState.first()) + notificationSettingsProxyMock.callbacks.send(.settingsDidChange) + try await deferred.fulfill() + } + + if case .loaded(let newNotificationSettingsState) = viewModel.state.notificationSettingsState { + XCTAssertFalse(newNotificationSettingsState.isDefault) + XCTAssertEqual(newNotificationSettingsState.mode, .allMessages) + } else { + XCTFail("invalid state") + } + } }