From f2b7faa18354bab1147c5f83a16cef26f89cadee Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Fri, 26 May 2023 15:47:12 +0200 Subject: [PATCH] Room's details edit screen (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RoomDetailsEditScreen template * Add navigation to the edit screen * Delete template code * Start RoomDetailsEditScreen UI * Manage power levels in RoomDetailsScreen * Start RoomDetailsEditScreenViewModel logic * Inject initial room state * Add cancel action * Expose set name/topic APIs in RoomProxy * Refine RoomDetailsEditScreen UI * Add save logic * Add localisations * Fix avatar image * Update localisations * Add “Add topic” button * Add feature flag * Add dismiss on save logic * Reduce throttling * Improve form logic * Fix UT build errors * Add media sheet * Add media preprocessing * Add LoadableEditableAvatarImage * Add condition on delete image * Add avatar save button logic * Add remove avatar logic * Cleanup * Fix edit bug in DM * Add upload avatar * Add focus * Add RoomDetailsViewModel UTs * Fix button style * Add UTs * Add empty topic ui test * Fix iPad sheet presentation * Revert topic appearance in room’s details * Address PR comments * Add UI tests --- ElementX.xcodeproj/project.pbxproj | 48 +++++ .../en.lproj/Localizable.strings | 8 +- .../Sources/Application/AppSettings.swift | 6 + ElementX/Sources/Generated/Strings.swift | 14 +- .../Mocks/Generated/GeneratedMocks.swift | 111 ++++++++++- .../Sources/Mocks/RoomMemberProxyMock.swift | 38 ++-- ElementX/Sources/Mocks/RoomProxyMock.swift | 4 +- .../Other/AccessibilityIdentifiers.swift | 1 + .../Views/OverridableAvatarImage.swift | 59 ++++++ .../Other/SwiftUI/Views/UserProfileCell.swift | 2 +- .../MockUserIndicatorController.swift | 9 +- .../DeveloperOptionsScreenModels.swift | 2 + .../DeveloperOptionsScreenViewModel.swift | 5 +- .../View/DeveloperOptionsScreen.swift | 7 + .../RoomDetailsEditScreenCoordinator.swift | 90 +++++++++ .../RoomDetailsEditScreenModels.swift | 73 ++++++++ .../RoomDetailsEditScreenViewModel.swift | 141 ++++++++++++++ ...omDetailsEditScreenViewModelProtocol.swift | 26 +++ .../View/RoomDetailsEditScreen.swift | 176 ++++++++++++++++++ .../RoomDetailsScreenCoordinator.swift | 26 +++ .../RoomDetailsScreenModels.swift | 14 ++ .../RoomDetailsScreenViewModel.swift | 14 +- .../View/RoomDetailsScreen.swift | 30 ++- .../Media/MediaUploadingPreprocessor.swift | 32 ++++ .../Room/RoomMember/RoomMemberProxy.swift | 4 + .../RoomMember/RoomMemberProxyProtocol.swift | 1 + .../Sources/Services/Room/RoomProxy.swift | 59 +++++- .../Services/Room/RoomProxyProtocol.swift | 18 +- .../UITests/UITestsAppCoordinator.swift | 34 ++++ .../UITests/UITestsScreenIdentifier.swift | 3 + .../RoomDetailsEditScreenUITests.swift | 31 +++ .../Sources/RoomDetailsScreenUITests.swift | 7 + ...ration.roomDetailsScreenWithEmptyTopic.png | 3 + ...GB-iPad-9th-generation.roomEditDetails.png | 3 + ...9th-generation.roomEditDetailsReadOnly.png | 3 + ...one-14.roomDetailsScreenWithEmptyTopic.png | 3 + .../en-GB-iPhone-14.roomEditDetails.png | 3 + ...n-GB-iPhone-14.roomEditDetailsReadOnly.png | 3 + ...ration.roomDetailsScreenWithEmptyTopic.png | 3 + ...do-iPad-9th-generation.roomEditDetails.png | 3 + ...9th-generation.roomEditDetailsReadOnly.png | 3 + ...one-14.roomDetailsScreenWithEmptyTopic.png | 3 + .../pseudo-iPhone-14.roomEditDetails.png | 3 + ...eudo-iPhone-14.roomEditDetailsReadOnly.png | 3 + .../Sources/InviteUsersViewModelTests.swift | 5 +- .../RoomDetailsEditScreenViewModelTests.swift | 135 ++++++++++++++ .../Sources/RoomDetailsViewModelTests.swift | 66 +++++++ 47 files changed, 1281 insertions(+), 54 deletions(-) create mode 100644 ElementX/Sources/Other/SwiftUI/Views/OverridableAvatarImage.swift create mode 100644 ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift create mode 100644 ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenModels.swift create mode 100644 ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModel.swift create mode 100644 ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift create mode 100644 UITests/Sources/RoomDetailsEditScreenUITests.swift create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetails.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetailsReadOnly.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithEmptyTopic.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetails.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetailsReadOnly.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetails.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetailsReadOnly.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithEmptyTopic.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetails.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetailsReadOnly.png create mode 100644 UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index cafbe05cd..09a560b25 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 09C83DDDB07C28364F325209 /* MockRoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */; }; 0AA0477E063E72B786A983CF /* AnalyticsPromptScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF2DA52B1DE7CAEC5422 /* AnalyticsPromptScreenViewModel.swift */; }; 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */; }; + 0BDA19079FD6E17C5AC62E22 /* RoomDetailsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */; }; 0BE4D5CBF86956410F071F91 /* CreateRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A657D96779D1DEB8EF1327 /* CreateRoomViewModel.swift */; }; 0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */; }; 0C47AE2CA7929CB3B0E2D793 /* ServerSelectionScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */; }; @@ -232,6 +233,7 @@ 651341E67C3514F9811A1EC1 /* LoginScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */; }; 652ACCF104A8CEF30788963C /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1423AB065857FA546444DB15 /* NotificationManager.swift */; }; 6530865EB9A8C0F0AF0216DA /* ServerSelectionScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9501D11B4258DFA33BA3B40F /* ServerSelectionScreenModels.swift */; }; + 659E5B766F76FDEC1BF393A4 /* RoomDetailsEditScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */; }; 65EDA77363BEDC40CDE43B43 /* InvitesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADEA322D2089391E049535 /* InvitesScreen.swift */; }; 661A664C6EDF856B05519206 /* FilePreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F562E2CBA002E8E1B6545C38 /* FilePreviewScreen.swift */; }; 663E198678778F7426A9B27D /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FAFE1C2149E6AC8156ED2B /* Collection.swift */; }; @@ -297,6 +299,7 @@ 7F64FA937B95924B3A44EC12 /* OnboardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8E75B9CB6C78BE8D09B1AF /* OnboardingScreen.swift */; }; 7FB0BDE26838F1A92782D5E1 /* MediaUploadPreviewScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B6C8690AEA1E49FF1BAF95 /* MediaUploadPreviewScreenUITests.swift */; }; 8024BE37156FF0A95A7A3465 /* AnalyticsPromptUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF11DD57D9FACF2A757AB024 /* AnalyticsPromptUITests.swift */; }; + 804C15D8ADE0EA7A5268F58A /* OverridableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */; }; 80D00A7C62AAB44F54725C43 /* PermalinkBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */; }; 80DEA2A4B20F9E279EAE6B2B /* UserProfile+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */; }; 8196A2E71ACC902DD69F24EE /* UserNotificationControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE6C5C756E1393202BA95CD /* UserNotificationControllerTests.swift */; }; @@ -386,6 +389,7 @@ 9D9690D2FD4CD26FF670620F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75EF87651B00A176AB08E97 /* AppDelegate.swift */; }; 9DC5FB22B8F86C3B51E907C1 /* HomeScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E4C37E9F0E53D3DF951AC /* HomeScreenUITests.swift */; }; 9DD5AA10E85137140FEA86A3 /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; + 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */; }; 9DF3F6318A4402305F5EB869 /* AnalyticsPromptScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8002D0392A476D2758B291 /* AnalyticsPromptScreen.swift */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; }; 9F19096BFA629C0AC282B1E4 /* CreateRoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */; }; @@ -411,6 +415,7 @@ A50849766F056FD1DB942DEA /* AlertInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EEB64CC6F3DF5B68736A6B4 /* AlertInfo.swift */; }; A5B9EF45C7B8ACEB4954AE36 /* LoginScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9780389F8A53E4D26E23DD03 /* LoginScreenViewModelProtocol.swift */; }; A5D551E5691749066E0E0C44 /* RoomDetailsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B440C4705E4B899BCB899 /* RoomDetailsScreenViewModel.swift */; }; + A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; }; A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; A6F713461DB62AC06293E7B7 /* FilePreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820637A0F9C2F562FF40CBC8 /* FilePreviewScreenModels.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; @@ -490,6 +495,7 @@ C4078364FD9FA00EA9D00A15 /* RoomMembersListScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CDF9A107BFE6C79B58D6B5 /* RoomMembersListScreenViewModelProtocol.swift */; }; C413D36D44F89DE63D3ADFA4 /* ReportContentScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A433BE28B40D418237BE37B5 /* ReportContentScreen.swift */; }; C4180F418235DAD9DD173951 /* TemplateScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9873076F224E4CE09D8BD47D /* TemplateScreenUITests.swift */; }; + C49FCC766673006B6D299F1C /* RoomDetailsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */; }; C4D2BCAA54E2C62B94B24AF4 /* InviteUsersScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E9B841EE4878283ECDB554 /* InviteUsersScreen.swift */; }; C4E0D03DF88242697545A9B7 /* UserIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1275D9CE0FFBA6E8E85426 /* UserIndicatorController.swift */; }; C4F69156C31A447FEFF2A47C /* DTHTMLElement+AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */; }; @@ -570,6 +576,7 @@ E67418DACEDBC29E988E6ACD /* message.caf in Resources */ = {isa = PBXBuildFile; fileRef = ED482057AE39D5C6D9C5F3D8 /* message.caf */; }; E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08E9043618AE5B0BF7B07E1 /* TemplateScreenViewModelTests.swift */; }; E77469C5CD7F7F58C0AC9752 /* test_pdf.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 3FFDA99C98BE05F43A92343B /* test_pdf.pdf */; }; + E78D429F18071545BF661A52 /* RoomDetailsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */; }; E794AB6ABE1FF5AF0573FEA1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9332DFE9642F0A46ECA0497B /* BlurHashEncode.swift */; }; E89536FC8C0E4B79E9842A78 /* RoomTimelineControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0197EAE9D45A662B8847B6 /* RoomTimelineControllerProtocol.swift */; }; E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */; }; @@ -596,6 +603,7 @@ F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; F0F82C3C848C865C3098AA52 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */; }; + F16109A6F6DF03DA26D59233 /* RoomDetailsEditScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122186B7CD1BC46A9C629DD9 /* RoomDetailsEditScreenUITests.swift */; }; F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57916A1578D8043BB0795441 /* GeneratedMocks.swift */; }; F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; F257F964493A9CD02A6F720C /* OnboardingPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2717AB91060260E5F4781 /* OnboardingPageView.swift */; }; @@ -681,6 +689,7 @@ 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.swift; sourceTree = ""; }; 00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModelTests.swift; sourceTree = ""; }; 00B62EE933FC3D5651AF4607 /* TimelineEventProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEventProxy.swift; sourceTree = ""; }; + 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = ""; }; 01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 024F7398C5FC12586FB10E9D /* EffectsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectsScene.swift; sourceTree = ""; }; 0287793F11C480E242B03DF5 /* UserDiscoveryServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceTest.swift; sourceTree = ""; }; @@ -704,6 +713,7 @@ 095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderProtocol.swift; sourceTree = ""; }; 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToggleStyle.swift; sourceTree = ""; }; 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; + 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = ""; }; 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = ""; }; 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSenderAvatarView.swift; sourceTree = ""; }; 0C671107BDFC6CD1778C0B4C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -723,6 +733,7 @@ 111B698739E3410E2CDB7144 /* MXLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MXLog.swift; sourceTree = ""; }; 11F7F3CF7E70518BD7D25E04 /* EmojiMartEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartEmoji.swift; sourceTree = ""; }; 1215A4FC53D2319E81AE8970 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + 122186B7CD1BC46A9C629DD9 /* RoomDetailsEditScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenUITests.swift; sourceTree = ""; }; 1222DB76B917EB8A55365BA5 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 127A57D053CE8C87B5EFB089 /* Consumable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumable.swift; sourceTree = ""; }; 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.swift; sourceTree = ""; }; @@ -735,6 +746,7 @@ 1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenModels.swift; sourceTree = ""; }; 15A657D96779D1DEB8EF1327 /* CreateRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModel.swift; sourceTree = ""; }; 16037EE9E9A52AF37B7818E3 /* AnalyticsSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenUITests.swift; sourceTree = ""; }; + 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = ""; }; 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = AppIcon.pdf; sourceTree = ""; }; 1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = ""; }; 1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = ""; }; @@ -754,6 +766,7 @@ 1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = ""; }; 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = ""; }; 1DF2717AB91060260E5F4781 /* OnboardingPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPageView.swift; sourceTree = ""; }; + 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelProtocol.swift; sourceTree = ""; }; 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DTHTMLElement+AttributedStringBuilder.swift"; sourceTree = ""; }; 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = ""; }; 1FD51B4D5173F7FC886F5360 /* NoticeRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -894,6 +907,7 @@ 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = ""; }; 63E1FF2DA52B1DE7CAEC5422 /* AnalyticsPromptScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenViewModel.swift; sourceTree = ""; }; 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenViewModelProtocol.swift; sourceTree = ""; }; + 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridableAvatarImage.swift; sourceTree = ""; }; 6493AC9979CEB1410302BFE3 /* RoomDetailsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenCoordinator.swift; sourceTree = ""; }; 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerScreenCoordinator.swift; sourceTree = ""; }; 653610CB5F9776EAAAB98155 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -1165,6 +1179,7 @@ D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServerSelectionScreenState.swift; sourceTree = ""; }; D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = ""; }; DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = ""; }; + DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = ""; }; DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsKeys.swift; sourceTree = ""; }; DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = ""; }; DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = ""; }; @@ -1183,6 +1198,7 @@ E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; E39CCFA7537FAD50386FDA00 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; + E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = ""; }; 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 = ""; }; @@ -1610,6 +1626,7 @@ B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */, C352359663A0E52BA20761EE /* LoadableImage.swift */, 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */, + 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */, C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */, 839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */, 923485F85E1D765EF9D20E88 /* UserProfileCell.swift */, @@ -2004,6 +2021,14 @@ path = View; sourceTree = ""; }; + 5F6CB68B44F6C587E463A934 /* View */ = { + isa = PBXGroup; + children = ( + DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 605F8221E52991786397FCC9 /* View */ = { isa = PBXGroup; children = ( @@ -2176,6 +2201,7 @@ 00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */, 6FB31A32C93D94930B253FBF /* PermalinkBuilderTests.swift */, 086C19086DD16E9B38E25954 /* ReportContentViewModelTests.swift */, + 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */, 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */, EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */, 69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */, @@ -2491,6 +2517,7 @@ 39B6C8690AEA1E49FF1BAF95 /* MediaUploadPreviewScreenUITests.swift */, 0C88046D6A070D9827181C4D /* OnboardingUITests.swift */, 4132F882A984ED971338EE9D /* ReportContentScreenUITests.swift */, + 122186B7CD1BC46A9C629DD9 /* RoomDetailsEditScreenUITests.swift */, 3BFDAF6918BB096C44788FC9 /* RoomDetailsScreenUITests.swift */, 0F19DBE940499D3E3DD405D8 /* RoomMemberDetailsScreenUITests.swift */, C5B7A755E985FA14469E86B2 /* RoomMembersListScreenUITests.swift */, @@ -2940,6 +2967,7 @@ 3F38EAC92E2281990E65DAF2 /* OnboardingScreen */, A448A3A8F764174C60CD0CA1 /* Other */, 5970F275D6014548DCED6106 /* ReportContentScreen */, + E71742A824A7192C8D378875 /* RoomDetailsEditScreen */, E703BBD16266053B8A193C7B /* RoomDetailsScreen */, B86CF59E083C82C2A842E4AD /* RoomMemberDetailsScreen */, D4DB8163C10389C069458252 /* RoomMemberListScreen */, @@ -2986,6 +3014,18 @@ path = RoomDetailsScreen; sourceTree = ""; }; + E71742A824A7192C8D378875 /* RoomDetailsEditScreen */ = { + isa = PBXGroup; + children = ( + 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */, + 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */, + E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */, + 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */, + 5F6CB68B44F6C587E463A934 /* View */, + ); + path = RoomDetailsEditScreen; + sourceTree = ""; + }; E74CD7681375AD2EAA34D66B /* Authentication */ = { isa = PBXGroup; children = ( @@ -3559,6 +3599,7 @@ 27E9263DA75E266690A37EB1 /* PermalinkBuilderTests.swift in Sources */, D415764645491F10344FC6AC /* Publisher.swift in Sources */, D53B80EF02C1062E68659EDD /* ReportContentViewModelTests.swift in Sources */, + 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */, EA974337FA7D040E7C74FE6E /* RoomDetailsViewModelTests.swift in Sources */, 6B31508C6334C617360C2EAB /* RoomMemberDetailsViewModelTests.swift in Sources */, CAF8755E152204F55F8D6B5B /* RoomMembersListViewModelTests.swift in Sources */, @@ -3820,6 +3861,7 @@ 7F64FA937B95924B3A44EC12 /* OnboardingScreen.swift in Sources */, CE7148E80F09B7305E026AC6 /* OnboardingViewModel.swift in Sources */, 992477AB8E3F3C36D627D32E /* OnboardingViewModelProtocol.swift in Sources */, + 804C15D8ADE0EA7A5268F58A /* OverridableAvatarImage.swift in Sources */, CD6A72B65D3B6076F4045C30 /* PHGPostHogConfiguration.swift in Sources */, 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */, 764AFCC225B044CF5F9B41E5 /* PaginationIndicatorRoomTimelineView.swift in Sources */, @@ -3841,6 +3883,11 @@ 8285FF4B2C2331758C437FF7 /* ReportContentScreenViewModelProtocol.swift in Sources */, A494741843F087881299ACF0 /* RestorationToken.swift in Sources */, 755EE5B0998C6A4D764D86E5 /* RoomAttachmentPicker.swift in Sources */, + 0BDA19079FD6E17C5AC62E22 /* RoomDetailsEditScreen.swift in Sources */, + E78D429F18071545BF661A52 /* RoomDetailsEditScreenCoordinator.swift in Sources */, + A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */, + 659E5B766F76FDEC1BF393A4 /* RoomDetailsEditScreenViewModel.swift in Sources */, + C49FCC766673006B6D299F1C /* RoomDetailsEditScreenViewModelProtocol.swift in Sources */, 126EE01D8BEAEF26105D83C5 /* RoomDetailsScreen.swift in Sources */, FA5A7E32B1920FCB4EEDC1BA /* RoomDetailsScreenCoordinator.swift in Sources */, DB079D1929B5A5F52D207C83 /* RoomDetailsScreenModels.swift in Sources */, @@ -4032,6 +4079,7 @@ 7FB0BDE26838F1A92782D5E1 /* MediaUploadPreviewScreenUITests.swift in Sources */, 6B15FF984906AAFCF9DC4F58 /* OnboardingUITests.swift in Sources */, BA0D3DDCEDD97502DAC4B6E9 /* ReportContentScreenUITests.swift in Sources */, + F16109A6F6DF03DA26D59233 /* RoomDetailsEditScreenUITests.swift in Sources */, 829062DD3C3F7016FE1A6476 /* RoomDetailsScreenUITests.swift in Sources */, A8771F5975A82759FA5138AE /* RoomMemberDetailsScreenUITests.swift in Sources */, 44121202B4A260C98BF615A7 /* RoomMembersListScreenUITests.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 6886b6ed6..12a19cc00 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -69,6 +69,7 @@ "common_file" = "File"; "common_gif" = "GIF"; "common_image" = "Image"; +"common_invite_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "common_leaving_room" = "Leaving room"; "common_link_copied_to_clipboard" = "Link copied to clipboard"; "common_loading" = "Loading…"; @@ -85,6 +86,7 @@ "common_replying_to" = "Replying to %1$@"; "common_report_a_bug" = "Report a bug"; "common_report_submitted" = "Report submitted"; +"common_room_name" = "Room name"; "common_search_for_someone" = "Search for someone"; "common_search_results" = "Search results"; "common_security" = "Security"; @@ -224,9 +226,12 @@ "screen_room_attachment_source_camera_video" = "Record a video"; "screen_room_attachment_source_files" = "Attachment"; "screen_room_attachment_source_gallery" = "Photo & Video Library"; +"screen_room_details_add_topic_title" = "Add topic"; "screen_room_details_already_a_member" = "Already a member"; "screen_room_details_already_invited" = "Already invited"; -"screen_room_details_edition_error" = "An error occurred when updating the room details"; +"screen_room_details_edit_room_title" = "Edit Room"; +"screen_room_details_edition_error" = "We were unable to update all the information for this room."; +"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_share_room_title" = "Share room"; @@ -259,7 +264,6 @@ "screen_signout_confirmation_dialog_title" = "Sign out"; "screen_signout_in_progress_dialog_content" = "Signing out…"; "screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat"; -"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received."; "session_verification_banner_message" = "Looks like you’re using a new device. Verify it’s you to access your encrypted messages."; "session_verification_banner_title" = "Access your message history"; "settings_rageshake" = "Rageshake"; diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 1cdf89647..9a1698e35 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -29,6 +29,7 @@ final class AppSettings { case shouldCollapseRoomStateEvents case startChatFlowEnabled case startChatUserSuggestionsEnabled + case editRoomDetailsFlowEnabled case invitesFlowEnabled case inviteMorePeopleFlowEnabled case readReceiptsEnabled @@ -196,4 +197,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.readReceiptsEnabled, defaultValue: false, storageType: .userDefaults(store)) var readReceiptsEnabled + + // MARK: Room details edit + + @UserPreference(key: UserDefaultsKeys.editRoomDetailsFlowEnabled, defaultValue: false, storageType: .userDefaults(store)) + var editRoomDetailsFlowEnabled } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index f831a3e03..da6a88227 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -154,6 +154,8 @@ public enum L10n { public static var commonGif: String { return L10n.tr("Localizable", "common_gif") } /// Image public static var commonImage: String { return L10n.tr("Localizable", "common_image") } + /// We can’t validate this user’s Matrix ID. The invite might not be received. + public static var commonInviteUnknownProfile: String { return L10n.tr("Localizable", "common_invite_unknown_profile") } /// Leaving room public static var commonLeavingRoom: String { return L10n.tr("Localizable", "common_leaving_room") } /// Link copied to clipboard @@ -192,6 +194,8 @@ public enum L10n { public static var commonReportABug: String { return L10n.tr("Localizable", "common_report_a_bug") } /// Report submitted public static var commonReportSubmitted: String { return L10n.tr("Localizable", "common_report_submitted") } + /// Room name + public static var commonRoomName: String { return L10n.tr("Localizable", "common_room_name") } /// Search for someone public static var commonSearchForSomeone: String { return L10n.tr("Localizable", "common_search_for_someone") } /// Search results @@ -586,12 +590,18 @@ public enum L10n { public static var screenRoomAttachmentSourceFiles: String { return L10n.tr("Localizable", "screen_room_attachment_source_files") } /// Photo & Video Library public static var screenRoomAttachmentSourceGallery: String { return L10n.tr("Localizable", "screen_room_attachment_source_gallery") } + /// Add topic + public static var screenRoomDetailsAddTopicTitle: String { return L10n.tr("Localizable", "screen_room_details_add_topic_title") } /// Already a member public static var screenRoomDetailsAlreadyAMember: String { return L10n.tr("Localizable", "screen_room_details_already_a_member") } /// Already invited public static var screenRoomDetailsAlreadyInvited: String { return L10n.tr("Localizable", "screen_room_details_already_invited") } - /// An error occurred when updating the room details + /// Edit Room + public static var screenRoomDetailsEditRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_edit_room_title") } + /// We were unable to update all the information for this room. public static var screenRoomDetailsEditionError: String { return L10n.tr("Localizable", "screen_room_details_edition_error") } + /// Unable to update room + public static var screenRoomDetailsEditionErrorTitle: String { return L10n.tr("Localizable", "screen_room_details_edition_error_title") } /// Messages are secured with locks. Only you and the recipients have the unique keys to unlock them. public static var screenRoomDetailsEncryptionEnabledSubtitle: String { return L10n.tr("Localizable", "screen_room_details_encryption_enabled_subtitle") } /// Message encryption enabled @@ -680,8 +690,6 @@ public enum L10n { public static var screenSignoutPreferenceItem: String { return L10n.tr("Localizable", "screen_signout_preference_item") } /// An error occurred when trying to start a chat public static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") } - /// We can’t validate this user’s Matrix ID. The invite might not be received. - public static var screenStartChatUnknownProfile: String { return L10n.tr("Localizable", "screen_start_chat_unknown_profile") } /// Looks like you’re using a new device. Verify it’s you to access your encrypted messages. public static var sessionVerificationBannerMessage: String { return L10n.tr("Localizable", "session_verification_banner_message") } /// Access your message history diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 744cb5349..7e44bd96c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -371,6 +371,27 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol { return unignoreUserReturnValue } } + //MARK: - canSendStateEvent + + var canSendStateEventTypeCallsCount = 0 + var canSendStateEventTypeCalled: Bool { + return canSendStateEventTypeCallsCount > 0 + } + var canSendStateEventTypeReceivedType: StateEventType? + var canSendStateEventTypeReceivedInvocations: [StateEventType] = [] + var canSendStateEventTypeReturnValue: Bool! + var canSendStateEventTypeClosure: ((StateEventType) -> Bool)? + + func canSendStateEvent(type: StateEventType) -> Bool { + canSendStateEventTypeCallsCount += 1 + canSendStateEventTypeReceivedType = type + canSendStateEventTypeReceivedInvocations.append(type) + if let canSendStateEventTypeClosure = canSendStateEventTypeClosure { + return canSendStateEventTypeClosure(type) + } else { + return canSendStateEventTypeReturnValue + } + } } class RoomProxyMock: RoomProxyProtocol { var id: String { @@ -419,11 +440,6 @@ class RoomProxyMock: RoomProxyProtocol { set(value) { underlyingMembersPublisher = value } } var underlyingMembersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never>! - var updatesPublisher: AnyPublisher { - get { return underlyingUpdatesPublisher } - set(value) { underlyingUpdatesPublisher = value } - } - var underlyingUpdatesPublisher: AnyPublisher! var invitedMembersCount: UInt { get { return underlyingInvitedMembersCount } set(value) { underlyingInvitedMembersCount = value } @@ -439,6 +455,11 @@ class RoomProxyMock: RoomProxyProtocol { set(value) { underlyingActiveMembersCount = value } } var underlyingActiveMembersCount: UInt! + var updatesPublisher: AnyPublisher { + get { return underlyingUpdatesPublisher } + set(value) { underlyingUpdatesPublisher = value } + } + var underlyingUpdatesPublisher: AnyPublisher! //MARK: - loadAvatarURLForUserId @@ -884,6 +905,86 @@ class RoomProxyMock: RoomProxyProtocol { return inviteUserIDReturnValue } } + //MARK: - setName + + var setNameCallsCount = 0 + var setNameCalled: Bool { + return setNameCallsCount > 0 + } + var setNameReceivedName: String? + var setNameReceivedInvocations: [String?] = [] + var setNameReturnValue: Result! + var setNameClosure: ((String?) async -> Result)? + + func setName(_ name: String?) async -> Result { + setNameCallsCount += 1 + setNameReceivedName = name + setNameReceivedInvocations.append(name) + if let setNameClosure = setNameClosure { + return await setNameClosure(name) + } else { + return setNameReturnValue + } + } + //MARK: - setTopic + + var setTopicCallsCount = 0 + var setTopicCalled: Bool { + return setTopicCallsCount > 0 + } + var setTopicReceivedTopic: String? + var setTopicReceivedInvocations: [String] = [] + var setTopicReturnValue: Result! + var setTopicClosure: ((String) async -> Result)? + + func setTopic(_ topic: String) async -> Result { + setTopicCallsCount += 1 + setTopicReceivedTopic = topic + setTopicReceivedInvocations.append(topic) + if let setTopicClosure = setTopicClosure { + return await setTopicClosure(topic) + } else { + return setTopicReturnValue + } + } + //MARK: - removeAvatar + + var removeAvatarCallsCount = 0 + var removeAvatarCalled: Bool { + return removeAvatarCallsCount > 0 + } + var removeAvatarReturnValue: Result! + var removeAvatarClosure: (() async -> Result)? + + func removeAvatar() async -> Result { + removeAvatarCallsCount += 1 + if let removeAvatarClosure = removeAvatarClosure { + return await removeAvatarClosure() + } else { + return removeAvatarReturnValue + } + } + //MARK: - uploadAvatar + + var uploadAvatarMediaCallsCount = 0 + var uploadAvatarMediaCalled: Bool { + return uploadAvatarMediaCallsCount > 0 + } + var uploadAvatarMediaReceivedMedia: MediaInfo? + var uploadAvatarMediaReceivedInvocations: [MediaInfo] = [] + var uploadAvatarMediaReturnValue: Result! + var uploadAvatarMediaClosure: ((MediaInfo) async -> Result)? + + func uploadAvatar(media: MediaInfo) async -> Result { + uploadAvatarMediaCallsCount += 1 + uploadAvatarMediaReceivedMedia = media + uploadAvatarMediaReceivedInvocations.append(media) + if let uploadAvatarMediaClosure = uploadAvatarMediaClosure { + return await uploadAvatarMediaClosure(media) + } else { + return uploadAvatarMediaReturnValue + } + } } class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol { var callbacks: PassthroughSubject { diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index 9a841c105..735203fde 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -23,11 +23,12 @@ struct RoomMemberProxyMockConfiguration { var avatarURL: URL? var membership: MembershipState var isNameAmbiguous = false - var powerLevel: Int - var normalizedPowerLevel: Int + var powerLevel = 50 + var normalizedPowerLevel = 50 var isAccountOwner = false var isIgnored = false var canInviteUsers = false + var canSendStateEvent: (StateEventType) -> Bool = { _ in true } } extension RoomMemberProxyMock { @@ -43,6 +44,7 @@ extension RoomMemberProxyMock { isAccountOwner = configuration.isAccountOwner isIgnored = configuration.isIgnored canInviteUsers = configuration.canInviteUsers + canSendStateEventTypeClosure = configuration.canSendStateEvent } // Mocks @@ -50,45 +52,35 @@ extension RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, - membership: .join, - powerLevel: 50, - normalizedPowerLevel: 50)) + membership: .join)) } static var mockInvitedAlice: RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, - membership: .invite, - powerLevel: 50, - normalizedPowerLevel: 50)) + membership: .invite)) } static var mockBob: RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil, - membership: .join, - powerLevel: 50, - normalizedPowerLevel: 50)) + membership: .join)) } static var mockCharlie: RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil, - membership: .join, - powerLevel: 50, - normalizedPowerLevel: 50)) + membership: .join)) } static var mockDan: RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@dan:matrix.org", displayName: "Dan", avatarURL: URL.picturesDirectory, - membership: .join, - powerLevel: 50, - normalizedPowerLevel: 50)) + membership: .join)) } static var mockMe: RoomMemberProxyMock { @@ -96,8 +88,6 @@ extension RoomMemberProxyMock { displayName: "Me", avatarURL: URL.picturesDirectory, membership: .join, - powerLevel: 50, - normalizedPowerLevel: 50, isAccountOwner: true, canInviteUsers: true)) } @@ -107,8 +97,14 @@ extension RoomMemberProxyMock { displayName: "Ignored", avatarURL: nil, membership: .join, - powerLevel: 50, - normalizedPowerLevel: 50, isIgnored: true)) } + + static func mockOwner(allowedStateEvents: [StateEventType]) -> RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@foo:some.org", + displayName: "User owner", + membership: .join, + isAccountOwner: true, + canSendStateEvent: { allowedStateEvents.contains($0) })) + } } diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 1f7c9cd03..335994793 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -19,7 +19,7 @@ import Foundation struct RoomProxyMockConfiguration { var id = UUID().uuidString - let name: String? = nil + var name: String? let displayName: String? var topic: String? var avatarURL: URL? @@ -75,5 +75,7 @@ extension RoomProxyMock { acceptInvitationClosure = { .success(()) } registerTimelineListenerIfNeededClosure = { .success([]) } underlyingUpdatesPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() + setNameClosure = { _ in .success(()) } + setTopicClosure = { _ in .success(()) } } } diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index a982bba90..509ac75b5 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -94,6 +94,7 @@ struct A11yIdentifiers { } struct RoomDetailsScreen { + let addTopic = "room_details-add_topic" let avatar = "room_details-avatar" let dmAvatar = "room_details-dm_avatar" let people = "room_details-people" diff --git a/ElementX/Sources/Other/SwiftUI/Views/OverridableAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/OverridableAvatarImage.swift new file mode 100644 index 000000000..877451686 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/OverridableAvatarImage.swift @@ -0,0 +1,59 @@ +// +// 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 SwiftUI + +struct OverridableAvatarImage: View { + private let overrideURL: URL? + private let url: URL? + private let name: String? + private let contentID: String? + private let avatarSize: AvatarSize + private let imageProvider: ImageProviderProtocol? + + @ScaledMetric private var frameSize: CGFloat + + init(overrideURL: URL?, url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, imageProvider: ImageProviderProtocol?) { + self.overrideURL = overrideURL + self.url = url + self.name = name + self.contentID = contentID + self.avatarSize = avatarSize + self.imageProvider = imageProvider + + _frameSize = ScaledMetric(wrappedValue: avatarSize.value) + } + + var body: some View { + if let overrideURL { + AsyncImage(url: overrideURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + ProgressView() + } + .frame(width: frameSize, height: frameSize) + .clipShape(Circle()) + } else { + LoadableAvatarImage(url: url, + name: name, + contentID: contentID, + avatarSize: avatarSize, + imageProvider: imageProvider) + } + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift b/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift index 0977bc84c..87ce86430 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift @@ -67,7 +67,7 @@ struct UserProfileCell: View { Text(Image(systemName: "exclamationmark.circle.fill")) .foregroundColor(.compound.textCriticalPrimary) - Text(L10n.screenStartChatUnknownProfile) + Text(L10n.commonInviteUnknownProfile) .foregroundColor(.secondary) } .font(.compound.bodyXS) diff --git a/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift b/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift index e10e50318..c5e0982f3 100644 --- a/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift +++ b/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import Foundation class MockUserIndicatorController: UserIndicatorControllerProtocol { @@ -23,5 +24,11 @@ class MockUserIndicatorController: UserIndicatorControllerProtocol { func retractAllIndicators() { } - var alertInfo: AlertInfo? + var alertInfo: AlertInfo? { + didSet { + alertInfoPublisher.send(alertInfo) + } + } + + let alertInfoPublisher: PassthroughSubject?, Never> = .init() } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 82c67944f..68c8319c3 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -30,6 +30,7 @@ struct DeveloperOptionsScreenViewStateBindings { var startChatUserSuggestionsEnabled: Bool var invitesFlowEnabled: Bool var inviteMorePeopleFlowEnabled: Bool + var editRoomDetailsFlowEnabled: Bool } enum DeveloperOptionsScreenViewAction { @@ -38,5 +39,6 @@ enum DeveloperOptionsScreenViewAction { case changedStartChatUserSuggestionsEnabled case changedInvitesFlowEnabled case changedInviteMorePeopleFlowEnabled + case changedEditRoomDetailsFlowEnabled case clearCache } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index d8f34c63d..6f983252e 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -29,7 +29,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve startChatFlowEnabled: appSettings.startChatFlowEnabled, startChatUserSuggestionsEnabled: appSettings.startChatUserSuggestionsEnabled, invitesFlowEnabled: appSettings.invitesFlowEnabled, - inviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled) + inviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled, + editRoomDetailsFlowEnabled: appSettings.editRoomDetailsFlowEnabled) let state = DeveloperOptionsScreenViewState(bindings: bindings) super.init(initialViewState: state) @@ -51,6 +52,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve appSettings.invitesFlowEnabled = state.bindings.invitesFlowEnabled case .changedInviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled = state.bindings.inviteMorePeopleFlowEnabled + case .changedEditRoomDetailsFlowEnabled: + appSettings.editRoomDetailsFlowEnabled = state.bindings.editRoomDetailsFlowEnabled case .clearCache: callback?(.clearCache) } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 3f7ffad63..21b63dd08 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -57,6 +57,13 @@ struct DeveloperOptionsScreen: View { .onChange(of: context.inviteMorePeopleFlowEnabled) { _ in context.send(viewAction: .changedInviteMorePeopleFlowEnabled) } + + Toggle(isOn: $context.editRoomDetailsFlowEnabled) { + Text("Show edit room details flow") + } + .onChange(of: context.editRoomDetailsFlowEnabled) { _ in + context.send(viewAction: .changedEditRoomDetailsFlowEnabled) + } } Section { diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift new file mode 100644 index 000000000..bd16552ee --- /dev/null +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift @@ -0,0 +1,90 @@ +// +// Copyright 2022 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 SwiftUI + +struct RoomDetailsEditScreenCoordinatorParameters { + let accountOwner: RoomMemberProxyProtocol + let mediaProvider: MediaProviderProtocol + let navigationStackCoordinator: NavigationStackCoordinator + let roomProxy: RoomProxyProtocol + let userIndicatorController: UserIndicatorControllerProtocol +} + +enum RoomDetailsEditScreenCoordinatorAction { + case dismiss +} + +final class RoomDetailsEditScreenCoordinator: CoordinatorProtocol { + private let parameters: RoomDetailsEditScreenCoordinatorParameters + private var viewModel: RoomDetailsEditScreenViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables: Set = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: RoomDetailsEditScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = RoomDetailsEditScreenViewModel(accountOwner: parameters.accountOwner, + mediaProvider: parameters.mediaProvider, + roomProxy: parameters.roomProxy, + userIndicatorController: parameters.userIndicatorController) + } + + func start() { + viewModel.actions + .sink { [weak self] action in + switch action { + case .cancel, .saveFinished: + self?.actionsSubject.send(.dismiss) + case .displayCameraPicker: + self?.displayMediaPickerWithSource(.camera) + case .displayMediaPicker: + self?.displayMediaPickerWithSource(.photoLibrary) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(RoomDetailsEditScreen(context: viewModel.context)) + } + + // MARK: Private + + private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) { + let stackCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator) + + let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in + guard let self else { return } + switch action { + case .cancel: + parameters.navigationStackCoordinator.setSheetCoordinator(nil) + case .selectMediaAtURL(let url): + parameters.navigationStackCoordinator.setSheetCoordinator(nil) + viewModel.didSelectMediaUrl(url: url) + } + } + + stackCoordinator.setRootCoordinator(mediaPickerCoordinator) + parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController) + } +} diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenModels.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenModels.swift new file mode 100644 index 000000000..51251c667 --- /dev/null +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenModels.swift @@ -0,0 +1,73 @@ +// +// Copyright 2022 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 + +enum RoomDetailsEditScreenViewModelAction { + case cancel + case saveFinished + case displayCameraPicker + case displayMediaPicker +} + +struct RoomDetailsEditScreenViewStateBindings { + var name: String + var topic: String + var showMediaSheet = false +} + +struct RoomDetailsEditScreenViewState: BindableState { + let roomID: String + let initialAvatarURL: URL? + let initialName: String + let initialTopic: String + let canEditAvatar: Bool + let canEditName: Bool + let canEditTopic: Bool + var avatarURL: URL? + var localMedia: MediaInfo? + + var bindings: RoomDetailsEditScreenViewStateBindings + + var nameDidChange: Bool { + bindings.name != initialName + } + + var topicDidChange: Bool { + bindings.topic != initialTopic + } + + var avatarDidChange: Bool { + localMedia != nil || avatarURL != initialAvatarURL + } + + var canSave: Bool { + !bindings.name.isEmpty && (avatarDidChange || nameDidChange || topicDidChange) + } + + var showDeleteImageAction: Bool { + localMedia != nil || avatarURL != nil + } +} + +enum RoomDetailsEditScreenViewAction { + case cancel + case save + case presentMediaSource + case displayCameraPicker + case displayMediaPicker + case removeImage +} diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModel.swift new file mode 100644 index 000000000..ef94b3ff9 --- /dev/null +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModel.swift @@ -0,0 +1,141 @@ +// +// Copyright 2022 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 SwiftUI + +typealias RoomDetailsEditScreenViewModelType = StateStoreViewModel + +class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDetailsEditScreenViewModelProtocol { + private let actionsSubject: PassthroughSubject = .init() + private let roomProxy: RoomProxyProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + private let mediaPreprocessor: MediaUploadingPreprocessor = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(accountOwner: RoomMemberProxyProtocol, + mediaProvider: MediaProviderProtocol, + roomProxy: RoomProxyProtocol, + userIndicatorController: UserIndicatorControllerProtocol) { + self.roomProxy = roomProxy + self.userIndicatorController = userIndicatorController + + let roomAvatar = roomProxy.avatarURL + let roomName = roomProxy.name + let roomTopic = roomProxy.topic + + super.init(initialViewState: RoomDetailsEditScreenViewState(roomID: roomProxy.id, + initialAvatarURL: roomAvatar, + initialName: roomName ?? "", + initialTopic: roomTopic ?? "", + canEditAvatar: accountOwner.canSendStateEvent(type: .roomAvatar), + canEditName: accountOwner.canSendStateEvent(type: .roomName), + canEditTopic: accountOwner.canSendStateEvent(type: .roomTopic), + avatarURL: roomAvatar, + bindings: .init(name: roomName ?? "", topic: roomTopic ?? "")), imageProvider: mediaProvider) + } + + // MARK: - Public + + override func process(viewAction: RoomDetailsEditScreenViewAction) { + switch viewAction { + case .cancel: + actionsSubject.send(.cancel) + case .save: + saveRoomDetails() + case .presentMediaSource: + state.bindings.showMediaSheet = true + case .displayCameraPicker: + actionsSubject.send(.displayCameraPicker) + case .displayMediaPicker: + actionsSubject.send(.displayMediaPicker) + case .removeImage: + if state.localMedia != nil { + state.localMedia = nil + } else { + state.avatarURL = nil + } + } + } + + func didSelectMediaUrl(url: URL) { + Task { + let userIndicatorID = UUID().uuidString + defer { + userIndicatorController.retractIndicatorWithId(userIndicatorID) + } + userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal, title: L10n.commonLoading, persistent: true)) + + let mediaResult = await mediaPreprocessor.processMedia(at: url) + + switch mediaResult { + case .success(.image): + state.localMedia = try? mediaResult.get() + case .failure, .success: + userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError, message: L10n.errorUnknown) + } + } + } + + // MARK: - Private + + private func saveRoomDetails() { + Task { + let userIndicatorID = UUID().uuidString + defer { + userIndicatorController.retractIndicatorWithId(userIndicatorID) + } + userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal, title: L10n.screenRoomDetailsUpdatingRoom, persistent: true)) + + do { + try await withThrowingTaskGroup(of: Void.self) { group in + if state.avatarDidChange { + group.addTask { + if let localMedia = await self.state.localMedia { + try await self.roomProxy.uploadAvatar(media: localMedia).get() + } else if await self.state.avatarURL == nil { + try await self.roomProxy.removeAvatar().get() + } + } + } + + if state.nameDidChange { + group.addTask { + try await self.roomProxy.setName(self.state.bindings.name).get() + } + } + + if state.topicDidChange { + group.addTask { + try await self.roomProxy.setTopic(self.state.bindings.topic).get() + } + } + + try await group.waitForAll() + } + + actionsSubject.send(.saveFinished) + } catch { + userIndicatorController.alertInfo = .init(id: .init(), + title: L10n.screenRoomDetailsEditionErrorTitle, + message: L10n.screenRoomDetailsEditionError) + } + } + } +} diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModelProtocol.swift new file mode 100644 index 000000000..04adc0438 --- /dev/null +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenViewModelProtocol.swift @@ -0,0 +1,26 @@ +// +// Copyright 2022 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 + +@MainActor +protocol RoomDetailsEditScreenViewModelProtocol { + var actions: AnyPublisher { get } + var context: RoomDetailsEditScreenViewModelType.Context { get } + + func didSelectMediaUrl(url: URL) +} diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift new file mode 100644 index 000000000..adbc3e17f --- /dev/null +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift @@ -0,0 +1,176 @@ +// +// Copyright 2022 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 SwiftUI + +struct RoomDetailsEditScreen: View { + @ObservedObject var context: RoomDetailsEditScreenViewModel.Context + @FocusState private var focus: Focus? + + private enum Focus { + case name + case topic + } + + var body: some View { + mainContent + .scrollContentBackground(.hidden) + .background(Color.element.formBackground.ignoresSafeArea()) + .scrollDismissesKeyboard(.immediately) + .navigationTitle(L10n.screenRoomDetailsEditRoomTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.actionCancel) { + context.send(viewAction: .cancel) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(L10n.actionSave) { + context.send(viewAction: .save) + focus = nil + } + .disabled(!context.viewState.canSave) + } + } + } + + // MARK: - Private + + private var mainContent: some View { + Form { + avatar + nameSection + topicSection + } + } + + private var avatar: some View { + Button { + context.send(viewAction: .presentMediaSource) + } label: { + OverridableAvatarImage(overrideURL: context.viewState.localMedia?.thumbnailURL, + url: context.viewState.avatarURL, + name: context.viewState.initialName, + contentID: context.viewState.roomID, + avatarSize: .user(on: .memberDetails), + imageProvider: context.imageProvider) + .overlay(alignment: .bottomTrailing) { + if context.viewState.canEditAvatar { + avatarOverlayIcon + } + } + .confirmationDialog("", isPresented: $context.showMediaSheet) { + mediaActionSheet + } + } + .buttonStyle(.plain) + .disabled(!context.viewState.canEditAvatar) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + } + + private var nameSection: some View { + Section { + let canEditName = context.viewState.canEditName + + TextField(L10n.commonRoomName, + text: $context.name, + prompt: canEditName ? Text(L10n.screenCreateRoomRoomNamePlaceholder) : nil, + axis: .horizontal) + .focused($focus, equals: .name) + .font(.compound.bodyLG) + .foregroundColor(.element.primaryContent) + .disabled(!canEditName) + .listRowBackground(canEditName ? nil : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } header: { + Text(L10n.commonRoomName) + .formSectionHeader() + } + .formSectionStyle() + } + + private var topicSection: some View { + Section { + let canEditTopic = context.viewState.canEditTopic + + TextField(L10n.commonTopic, + text: $context.topic, + prompt: canEditTopic ? Text(L10n.screenCreateRoomTopicPlaceholder) : nil, + axis: .vertical) + .focused($focus, equals: .topic) + .font(.compound.bodyLG) + .foregroundColor(.element.primaryContent) + .disabled(!canEditTopic) + .listRowBackground(canEditTopic ? nil : Color.clear) + .lineLimit(3, reservesSpace: true) + } header: { + Text(L10n.commonTopic) + .formSectionHeader() + } + .formSectionStyle() + } + + private var avatarOverlayIcon: some View { + Image(systemName: "camera") + .padding(2) + .imageScale(.small) + .foregroundColor(.white) + .background { + Circle() + .foregroundColor(.black) + .aspectRatio(1, contentMode: .fill) + } + } + + @ViewBuilder + private var mediaActionSheet: some View { + Button { + context.send(viewAction: .displayCameraPicker) + } label: { + Text(L10n.actionTakePhoto) + } + Button { + context.send(viewAction: .displayMediaPicker) + } label: { + Text(L10n.actionChoosePhoto) + } + if context.viewState.showDeleteImageAction { + Button(role: .destructive) { + context.send(viewAction: .removeImage) + } label: { + Text(L10n.actionRemove) + } + } + } +} + +// MARK: - Previews + +struct RoomDetailsEditScreen_Previews: PreviewProvider { + static let viewModel = RoomDetailsEditScreenViewModel(accountOwner: RoomMemberProxyMock.mockAlice, + mediaProvider: MockMediaProvider(), + roomProxy: RoomProxyMock(with: .init(name: "Room", displayName: "Room")), + userIndicatorController: MockUserIndicatorController()) + + static var previews: some View { + NavigationStack { + RoomDetailsEditScreen(context: viewModel.context) + } + } +} diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift index 745a6c87c..d75877dcc 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenCoordinator.swift @@ -62,6 +62,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { self.callback?(.cancel) case .leftRoom: self.callback?(.leftRoom) + case .requestEditDetailsPresentation(let accountOwner): + self.presentRoomDetailsEditScreen(accountOwner: accountOwner) } } } @@ -116,6 +118,30 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol { parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController) } + private func presentRoomDetailsEditScreen(accountOwner: RoomMemberProxyProtocol) { + let navigationStackCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator) + + let roomDetailsEditParameters = RoomDetailsEditScreenCoordinatorParameters(accountOwner: accountOwner, + mediaProvider: parameters.mediaProvider, + navigationStackCoordinator: navigationStackCoordinator, + roomProxy: parameters.roomProxy, + userIndicatorController: userIndicatorController) + let roomDetailsEditCoordinator = RoomDetailsEditScreenCoordinator(parameters: roomDetailsEditParameters) + + roomDetailsEditCoordinator.actions.sink { [weak self] action in + switch action { + case .dismiss: + self?.parameters.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setRootCoordinator(roomDetailsEditCoordinator) + + parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController) + } + private func toggleUser(_ user: UserProfile) { var selectedUsers = selectedUsers.value if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift index e0b2c8d6d..76502f3bd 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenModels.swift @@ -26,6 +26,7 @@ enum RoomDetailsScreenViewModelAction { case requestInvitePeoplePresentation([RoomMemberProxyProtocol]) case leftRoom case cancel + case requestEditDetailsPresentation(RoomMemberProxyProtocol) } // MARK: View @@ -43,10 +44,21 @@ struct RoomDetailsScreenViewState: BindableState { var joinedMembersCount = 0 var isProcessingIgnoreRequest = false var canInviteUsers = false + var canEditRoomName = false + var canEditRoomTopic = false + var canEditRoomAvatar = false var isLoadingMembers: Bool { members.isEmpty } + + var canEdit: Bool { + !isDirect && ServiceLocator.shared.settings.editRoomDetailsFlowEnabled && (canEditRoomName || canEditRoomTopic || canEditRoomAvatar) + } + + var hasTopicSection: Bool { + topic != nil || (canEdit && canEditRoomTopic) + } var bindings: RoomDetailsScreenViewStateBindings @@ -130,6 +142,8 @@ enum RoomDetailsScreenViewAction { case processTapLeave case processTapIgnore case processTapUnignore + case processTapEdit + case processTapAddTopic case confirmLeave case ignoreConfirmed case unignoreConfirmed diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index 2cce10457..678f84d66 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 roomProxy: RoomProxyProtocol private var members: [RoomMemberProxyProtocol] = [] private var dmRecipient: RoomMemberProxyProtocol? + private var accountOwner: RoomMemberProxyProtocol? @CancellableTask private var buildMembersDetailsTask: Task? @@ -52,6 +53,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr // MARK: - Public + // swiftlint:disable:next cyclomatic_complexity override func process(viewAction: RoomDetailsScreenViewAction) { switch viewAction { case .processTapPeople: @@ -71,6 +73,12 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr state.bindings.ignoreUserRoomAlertItem = .init(action: .ignore) case .processTapUnignore: state.bindings.ignoreUserRoomAlertItem = .init(action: .unignore) + case .processTapEdit, .processTapAddTopic: + guard let accountOwner else { + MXLog.error("Missing account owner when presenting the room's edit details screen") + return + } + callback?(.requestEditDetailsPresentation(accountOwner)) case .ignoreConfirmed: Task { await ignore() } case .unignoreConfirmed: @@ -105,13 +113,17 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr self.state.joinedMembersCount = roomMembersDetails.joinedMembersCount self.state.dmRecipient = self.dmRecipient.map(RoomMemberDetails.init(withProxy:)) self.state.canInviteUsers = roomMembersDetails.accountOwner?.canInviteUsers ?? false + self.state.canEditRoomName = roomMembersDetails.accountOwner?.canSendStateEvent(type: .roomName) ?? false + self.state.canEditRoomTopic = roomMembersDetails.accountOwner?.canSendStateEvent(type: .roomTopic) ?? false + self.state.canEditRoomAvatar = roomMembersDetails.accountOwner?.canSendStateEvent(type: .roomAvatar) ?? false self.members = members + self.accountOwner = roomMembersDetails.accountOwner } } .store(in: &cancellables) roomProxy.updatesPublisher - .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) + .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self else { return } self.state.title = self.roomProxy.roomTitle diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift index e8083bb8e..d4b2218ba 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/View/RoomDetailsScreen.swift @@ -50,6 +50,15 @@ struct RoomDetailsScreen: View { .alert(item: $context.ignoreUserRoomAlertItem, actions: blockUserAlertActions, message: blockUserAlertMessage) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if context.viewState.canEdit { + Button(L10n.actionEdit) { + context.send(viewAction: .processTapEdit) + } + } + } + } } // MARK: - Private @@ -98,11 +107,23 @@ struct RoomDetailsScreen: View { @ViewBuilder private var topicSection: some View { - if let topic = context.viewState.topic { + if context.viewState.hasTopicSection { Section { - Text(topic) - .foregroundColor(.element.secondaryContent) - .font(.compound.bodySM) + if let topic = context.viewState.topic, !topic.isEmpty { + Text(topic) + .foregroundColor(.element.secondaryContent) + .font(.compound.bodySM) + .lineLimit(3) + } else { + Button { + context.send(viewAction: .processTapAddTopic) + } label: { + Text(L10n.screenRoomDetailsAddTopicTitle) + .foregroundColor(.element.primaryContent) + .font(.compound.bodyLG) + } + .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.addTopic) + } } header: { Text(L10n.commonTopic) .formSectionHeader() @@ -142,7 +163,6 @@ struct RoomDetailsScreen: View { .listRowSeparatorTint(.element.quinaryContent) .buttonStyle(FormButtonStyle(accessory: context.viewState.isLoadingMembers ? nil : .navigationLink)) .foregroundColor(.element.primaryContent) - .disabled(context.viewState.isLoadingMembers) } diff --git a/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift b/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift index cdd04180d..5dcef5450 100644 --- a/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift +++ b/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift @@ -41,6 +41,38 @@ enum MediaInfo { case video(videoURL: URL, thumbnailURL: URL, videoInfo: VideoInfo) case audio(audioURL: URL, audioInfo: AudioInfo) case file(fileURL: URL, fileInfo: FileInfo) + + var mimeType: String? { + switch self { + case .image(_, _, let imageInfo): + return imageInfo.mimetype + case .video(_, _, let videoInfo): + return videoInfo.mimetype + case .audio(_, let audioInfo): + return audioInfo.mimetype + case .file(_, let fileInfo): + return fileInfo.mimetype + } + } + + var url: URL { + switch self { + case .image(let url, _, _), + .video(let url, _, _), + .audio(let url, _), + .file(let url, _): + return url + } + } + + var thumbnailURL: URL? { + switch self { + case .image(_, let url, _), .video(_, let url, _): + return url + case .audio, .file: + return nil + } + } } private struct ImageProcessingInfo { diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift index 32ae3cfb0..c2782c9ed 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift @@ -70,6 +70,10 @@ final class RoomMemberProxy: RoomMemberProxyProtocol { var canInviteUsers: Bool { member.canInvite() } + + func canSendStateEvent(type: StateEventType) -> Bool { + member.canSendState(stateEvent: type) + } func ignoreUser() async -> Result { sendAccountDataEventBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true) diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index 8c791976a..dce8ce03d 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -37,6 +37,7 @@ protocol RoomMemberProxyProtocol { func ignoreUser() async -> Result func unignoreUser() async -> Result + func canSendStateEvent(type: StateEventType) -> Bool } extension RoomMemberProxyProtocol { diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index c7e7cc604..ac72b4661 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -130,7 +130,19 @@ class RoomProxy: RoomProxyProtocol { // return trusted image for now, should be updated after verification status known return Asset.Images.encryptionTrusted.image } + + var invitedMembersCount: UInt { + UInt(room.invitedMembersCount()) + } + + var joinedMembersCount: UInt { + UInt(room.joinedMembersCount()) + } + var activeMembersCount: UInt { + UInt(room.activeMembersCount()) + } + func loadAvatarURLForUserId(_ userId: String) async -> Result { do { guard let urlString = try await Task.dispatch(on: lowPriorityDispatchQueue, { @@ -486,18 +498,51 @@ class RoomProxy: RoomProxyProtocol { } } - var invitedMembersCount: UInt { - UInt(room.invitedMembersCount()) + func setName(_ name: String?) async -> Result { + await Task.dispatch(on: .global()) { + do { + return try .success(self.room.setName(name: name)) + } catch { + return .failure(.failedSettingRoomName) + } + } + } + + func setTopic(_ topic: String) async -> Result { + await Task.dispatch(on: .global()) { + do { + return try .success(self.room.setTopic(topic: topic)) + } catch { + return .failure(.failedSettingRoomTopic) + } + } } - var joinedMembersCount: UInt { - UInt(room.joinedMembersCount()) + func removeAvatar() async -> Result { + await Task.dispatch(on: .global()) { + do { + return try .success(self.room.removeAvatar()) + } catch { + return .failure(.failedRemovingAvatar) + } + } } - var activeMembersCount: UInt { - UInt(room.activeMembersCount()) + func uploadAvatar(media: MediaInfo) async -> Result { + await Task.dispatch(on: .global()) { + guard case let .image(imageURL, _, _) = media, let mimeType = media.mimeType else { + return .failure(.failedUploadingAvatar) + } + + do { + let data = try Data(contentsOf: imageURL) + return try .success(self.room.uploadAvatar(mimeType: mimeType, data: [UInt8](data))) + } catch { + return .failure(.failedUploadingAvatar) + } + } } - + // MARK: - Private /// Force the timeline to load member details so it can populate sender profiles whenever we add a timeline listener diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index f7f9dd750..53c228600 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -37,6 +37,10 @@ enum RoomProxyError: Error { case failedAcceptingInvite case failedRejectingInvite case failedInvitingUser + case failedSettingRoomName + case failedSettingRoomTopic + case failedRemovingAvatar + case failedUploadingAvatar } @MainActor @@ -61,6 +65,12 @@ protocol RoomProxyProtocol { var membersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never> { get } + var invitedMembersCount: UInt { get } + + var joinedMembersCount: UInt { get } + + var activeMembersCount: UInt { get } + /// Publishes the room's updates. /// The publisher starts publishing after the first call to `registerTimelineListenerIfNeeded()` /// The thread on which this publisher sends the output isn't defined. @@ -114,11 +124,13 @@ protocol RoomProxyProtocol { func invite(userID: String) async -> Result - var invitedMembersCount: UInt { get } + func setName(_ name: String?) async -> Result - var joinedMembersCount: UInt { get } + func setTopic(_ topic: String) async -> Result - var activeMembersCount: UInt { get } + func removeAvatar() async -> Result + + func uploadAvatar(media: MediaInfo) async -> Result } extension RoomProxyProtocol { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 7a43f8e30..07bf821cc 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -15,6 +15,7 @@ // import Combine +import MatrixRustSDK import SwiftUI import UIKit @@ -321,6 +322,24 @@ class MockScreen: Identifiable { userDiscoveryService: UserDiscoveryServiceMock())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .roomDetailsScreenWithEmptyTopic: + ServiceLocator.shared.settings.editRoomDetailsFlowEnabled = true + let navigationStackCoordinator = NavigationStackCoordinator() + let members: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: [.roomTopic]), .mockBob, .mockCharlie] + let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier", + displayName: "Room", + topic: nil, + avatarURL: URL.picturesDirectory, + isDirect: false, + isEncrypted: true, + canonicalAlias: "#mock:room.org", + members: members)) + let coordinator = RoomDetailsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, + roomProxy: roomProxy, + mediaProvider: MockMediaProvider(), + userDiscoveryService: UserDiscoveryServiceMock())) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator case .roomDetailsScreenWithInvite: ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled = true let navigationStackCoordinator = NavigationStackCoordinator() @@ -335,6 +354,21 @@ class MockScreen: Identifiable { userDiscoveryService: UserDiscoveryServiceMock())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .roomEditDetails, .roomEditDetailsReadOnly: + let allowedStateEvents: [StateEventType] = id == .roomEditDetails ? [.roomAvatar, .roomName, .roomTopic] : [] + let navigationStackCoordinator = NavigationStackCoordinator() + let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier", + name: "Room", + displayName: "Room", + topic: "What a cool topic!", + avatarURL: .picturesDirectory)) + let coordinator = RoomDetailsEditScreenCoordinator(parameters: .init(accountOwner: RoomMemberProxyMock.mockOwner(allowedStateEvents: allowedStateEvents), + mediaProvider: MockMediaProvider(), + navigationStackCoordinator: navigationStackCoordinator, + roomProxy: roomProxy, + userIndicatorController: MockUserIndicatorController())) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator case .roomMembersListScreen: let navigationStackCoordinator = NavigationStackCoordinator() let members: [RoomMemberProxyMock] = [.mockAlice, .mockBob, .mockCharlie] diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index e1013ae4d..3f298cef6 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -43,8 +43,11 @@ enum UITestsScreenIdentifier: String { case userSessionScreen case roomDetailsScreen case roomDetailsScreenWithRoomAvatar + case roomDetailsScreenWithEmptyTopic case roomDetailsScreenWithInvite case roomDetailsScreenDmDetails + case roomEditDetails + case roomEditDetailsReadOnly case roomMembersListScreen case roomMembersListScreenPendingInvites case roomMemberDetailsAccountOwner diff --git a/UITests/Sources/RoomDetailsEditScreenUITests.swift b/UITests/Sources/RoomDetailsEditScreenUITests.swift new file mode 100644 index 000000000..5a6314327 --- /dev/null +++ b/UITests/Sources/RoomDetailsEditScreenUITests.swift @@ -0,0 +1,31 @@ +// +// Copyright 2022 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 ElementX +import XCTest + +@MainActor +class RoomDetailsEditScreenUITests: XCTestCase { + func testEditableRoom() async throws { + let app = Application.launch(.roomEditDetails) + try await app.assertScreenshot(.roomEditDetails) + } + + func testReadOnlyRoom() async throws { + let app = Application.launch(.roomEditDetailsReadOnly) + try await app.assertScreenshot(.roomEditDetailsReadOnly) + } +} diff --git a/UITests/Sources/RoomDetailsScreenUITests.swift b/UITests/Sources/RoomDetailsScreenUITests.swift index 2a2d83368..1665d8ea3 100644 --- a/UITests/Sources/RoomDetailsScreenUITests.swift +++ b/UITests/Sources/RoomDetailsScreenUITests.swift @@ -35,6 +35,13 @@ class RoomDetailsScreenUITests: XCTestCase { try await app.assertScreenshot(.roomDetailsScreenWithRoomAvatar) } + func testInitialStateComponentsWithEmptyTopic() async throws { + let app = Application.launch(.roomDetailsScreenWithEmptyTopic) + + XCTAssert(app.buttons[A11yIdentifiers.roomDetailsScreen.addTopic].waitForExistence(timeout: 1)) + try await app.assertScreenshot(.roomDetailsScreenWithEmptyTopic) + } + func testInitialStateComponentsWithInvite() async throws { let app = Application.launch(.roomDetailsScreenWithInvite) diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png new file mode 100644 index 000000000..f6b6f7dbf --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:412217a207ef9a3615aec0cd8c6fa6c9eb5f78aa25389b227d2bed99c1245291 +size 109946 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetails.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetails.png new file mode 100644 index 000000000..10754728d --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetails.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f1c4b2bd06eca5a87809115c557c2bd56aa932d0d3beeadabea1151ca908a49 +size 78981 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetailsReadOnly.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetailsReadOnly.png new file mode 100644 index 000000000..cbb478377 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEditDetailsReadOnly.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae566dbc1b03a1cfdb1b3b1412c79c2b0801b3f60f89180d60be7f8819782449 +size 75125 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithEmptyTopic.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithEmptyTopic.png new file mode 100644 index 000000000..b7d55557f --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithEmptyTopic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9aee2d012e7519e63ff561684c8f12162c9fadceff533cb974e6fbdb42cb46a +size 137771 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetails.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetails.png new file mode 100644 index 000000000..617f03e3a --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetails.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0410d0f1a5b47c01c7fa4349c8aa10ee9a41e7fc550621ca6f10e8b1dc9e099a +size 87208 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetailsReadOnly.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetailsReadOnly.png new file mode 100644 index 000000000..01014c262 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEditDetailsReadOnly.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4916268dcb63398ae44654dc5f98386102d0c9b3516fedfd75a6868dc7d77a1b +size 79861 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png new file mode 100644 index 000000000..3191074e7 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomDetailsScreenWithEmptyTopic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f3f0e62b3f40ee32e2c0547d3bffefd66064caa2377b78e0dc28e92cf329229 +size 124694 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetails.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetails.png new file mode 100644 index 000000000..c5de91813 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetails.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95180272e203386363518550eaca4ab66a5362a3514e8046b72e2d5010380fab +size 81651 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetailsReadOnly.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetailsReadOnly.png new file mode 100644 index 000000000..f5d38b2b3 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEditDetailsReadOnly.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dff46af4fb867cfe29c989b5c0e4c3ec7db25f2af19ca60e4ea075f65ac719ce +size 78457 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithEmptyTopic.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithEmptyTopic.png new file mode 100644 index 000000000..041673d60 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomDetailsScreenWithEmptyTopic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5b8f42166bfee1c2f1a82db462465e03ac95ea35703d4328c7b5dfd4e071ad9 +size 170859 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetails.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetails.png new file mode 100644 index 000000000..0919dbf8a --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetails.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f2e6eb2365ccd1c1a0ace52872a8e84f191736dd7b74c7898256c420e30fe73 +size 90556 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetailsReadOnly.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetailsReadOnly.png new file mode 100644 index 000000000..3ada5f01e --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEditDetailsReadOnly.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:232a667d2fd91bd1746509b186db7eb9676a4732110a84e3c9543808abf1d2f5 +size 82817 diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift index 2aa247c81..6699e5d55 100644 --- a/UnitTests/Sources/InviteUsersViewModelTests.swift +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -65,9 +65,8 @@ class InviteUsersScreenViewModelTests: XCTestCase { _ = await viewModel.context.$viewState.values.first(where: { $0.membershipState.isEmpty == false }) context.send(viewAction: .toggleUser(.mockAlice)) - Task { - await Task.yield() - context.send(viewAction: .proceed) + Task.detached(priority: .low) { + await self.context.send(viewAction: .proceed) } let action = await viewModel.actions.values.first() diff --git a/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift b/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift new file mode 100644 index 000000000..017f2ed1c --- /dev/null +++ b/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift @@ -0,0 +1,135 @@ +// +// Copyright 2022 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 MatrixRustSDK +import XCTest + +@testable import ElementX + +@MainActor +class RoomDetailsEditScreenViewModelTests: XCTestCase { + var viewModel: RoomDetailsEditScreenViewModel! + + var userIndicatorController: MockUserIndicatorController! + + var context: RoomDetailsEditScreenViewModelType.Context { + viewModel.context + } + + func testCannotSaveOnLanding() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + XCTAssertFalse(context.viewState.canSave) + } + + func testCanEdit() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + XCTAssertTrue(context.viewState.canEditAvatar) + XCTAssertTrue(context.viewState.canEditName) + XCTAssertTrue(context.viewState.canEditTopic) + } + + func testCannotEdit() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: []), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + XCTAssertFalse(context.viewState.canEditAvatar) + XCTAssertFalse(context.viewState.canEditName) + XCTAssertFalse(context.viewState.canEditTopic) + } + + func testNameDidChange() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + context.name = "name" + XCTAssertTrue(context.viewState.nameDidChange) + XCTAssertTrue(context.viewState.canSave) + } + + func testTopicDidChange() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + context.topic = "topic" + XCTAssertTrue(context.viewState.topicDidChange) + XCTAssertTrue(context.viewState.canSave) + } + + func testAvatarDidChange() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room", avatarURL: .picturesDirectory)) + context.send(viewAction: .removeImage) + XCTAssertTrue(context.viewState.avatarDidChange) + XCTAssertTrue(context.viewState.canSave) + } + + func testEmptyNameCannotBeSaved() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + context.name = "" + XCTAssertFalse(context.viewState.canSave) + } + + func testSaveShowsSheet() { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + context.name = "name" + XCTAssertFalse(context.showMediaSheet) + context.send(viewAction: .presentMediaSource) + XCTAssertTrue(context.showMediaSheet) + } + + func testSaveTriggersViewModelAction() async { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + context.name = "name" + context.send(viewAction: .save) + let action = await viewModel.actions.values.first() + XCTAssertEqual(action, .saveFinished) + } + + func testErrorShownOnFailedFetchOfMedia() async throws { + setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), + roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) + viewModel.didSelectMediaUrl(url: .picturesDirectory) + + let alertInfo = await userIndicatorController.alertInfoPublisher + .compactMap { $0 } + .values + .first() + + XCTAssertNotNil(alertInfo) + } + + // MARK: - Private + + private func setupViewModel(accountOwner: RoomMemberProxyMock, roomProxyConfiguration: RoomProxyMockConfiguration) { + userIndicatorController = MockUserIndicatorController() + viewModel = .init(accountOwner: accountOwner, + mediaProvider: MockMediaProvider(), + roomProxy: RoomProxyMock(with: roomProxyConfiguration), + userIndicatorController: userIndicatorController) + } +} + +private extension ImageInfo { + static let mock: ImageInfo = .init(height: nil, + width: nil, + mimetype: nil, + size: nil, + thumbnailInfo: nil, + thumbnailSource: nil, + blurhash: nil) +} diff --git a/UnitTests/Sources/RoomDetailsViewModelTests.swift b/UnitTests/Sources/RoomDetailsViewModelTests.swift index 1b4818b9b..10f6fd315 100644 --- a/UnitTests/Sources/RoomDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsViewModelTests.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import MatrixRustSDK import XCTest @testable import ElementX @@ -27,6 +28,9 @@ class RoomDetailsScreenViewModelTests: XCTestCase { override func setUp() { roomProxyMock = RoomProxyMock(with: .init(displayName: "Test")) viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + AppSettings.reset() + ServiceLocator.shared.settings.editRoomDetailsFlowEnabled = true } func testLeaveRoomTappedWhenPublic() async { @@ -219,4 +223,66 @@ class RoomDetailsScreenViewModelTests: XCTestCase { await context.nextViewState() XCTAssertEqual(roomProxyMock.registerTimelineListenerIfNeededCallsCount, 1) } + + func testCanEditAvatar() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: [.roomAvatar]), .mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isDirect: false, isPublic: false, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + _ = await context.$viewState.values.first(where: { !$0.members.isEmpty }) + + XCTAssertTrue(context.viewState.canEditRoomAvatar) + XCTAssertFalse(context.viewState.canEditRoomName) + XCTAssertFalse(context.viewState.canEditRoomTopic) + XCTAssertTrue(context.viewState.canEdit) + } + + func testCanEditName() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: [.roomName]), .mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isDirect: false, isPublic: false, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + _ = await context.$viewState.values.first(where: { !$0.members.isEmpty }) + + XCTAssertFalse(context.viewState.canEditRoomAvatar) + XCTAssertTrue(context.viewState.canEditRoomName) + XCTAssertFalse(context.viewState.canEditRoomTopic) + XCTAssertTrue(context.viewState.canEdit) + } + + func testCanEditTopic() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: [.roomTopic]), .mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isDirect: false, isPublic: false, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + _ = await context.$viewState.values.first(where: { !$0.members.isEmpty }) + + XCTAssertFalse(context.viewState.canEditRoomAvatar) + XCTAssertFalse(context.viewState.canEditRoomName) + XCTAssertTrue(context.viewState.canEditRoomTopic) + XCTAssertTrue(context.viewState.canEdit) + } + + func testCannotEditRoom() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: []), .mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isDirect: false, isPublic: false, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + _ = await context.$viewState.values.first(where: { !$0.members.isEmpty }) + + XCTAssertFalse(context.viewState.canEditRoomAvatar) + XCTAssertFalse(context.viewState.canEditRoomName) + XCTAssertFalse(context.viewState.canEditRoomTopic) + XCTAssertFalse(context.viewState.canEdit) + } + + func testCannotEditDirectRoom() async { + let mockedMembers: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), .mockBob, .mockAlice] + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isDirect: true, isPublic: false, members: mockedMembers)) + viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + + _ = await context.$viewState.values.first(where: { !$0.members.isEmpty }) + + XCTAssertFalse(context.viewState.canEdit) + } }