Room's details edit screen (#956)

* 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
This commit is contained in:
Alfonso Grillo 2023-05-26 15:47:12 +02:00 committed by GitHub
parent 524997438f
commit f2b7faa183
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1281 additions and 54 deletions

View File

@ -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 = "<group>"; };
00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModelTests.swift; sourceTree = "<group>"; };
00B62EE933FC3D5651AF4607 /* TimelineEventProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEventProxy.swift; sourceTree = "<group>"; };
00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = "<group>"; };
01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
024F7398C5FC12586FB10E9D /* EffectsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectsScene.swift; sourceTree = "<group>"; };
0287793F11C480E242B03DF5 /* UserDiscoveryServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceTest.swift; sourceTree = "<group>"; };
@ -704,6 +713,7 @@
095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderProtocol.swift; sourceTree = "<group>"; };
0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToggleStyle.swift; sourceTree = "<group>"; };
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = "<group>"; };
0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = "<group>"; };
0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = "<group>"; };
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSenderAvatarView.swift; sourceTree = "<group>"; };
0C671107BDFC6CD1778C0B4C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@ -723,6 +733,7 @@
111B698739E3410E2CDB7144 /* MXLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MXLog.swift; sourceTree = "<group>"; };
11F7F3CF7E70518BD7D25E04 /* EmojiMartEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartEmoji.swift; sourceTree = "<group>"; };
1215A4FC53D2319E81AE8970 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
122186B7CD1BC46A9C629DD9 /* RoomDetailsEditScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenUITests.swift; sourceTree = "<group>"; };
1222DB76B917EB8A55365BA5 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
127A57D053CE8C87B5EFB089 /* Consumable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumable.swift; sourceTree = "<group>"; };
127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.swift; sourceTree = "<group>"; };
@ -735,6 +746,7 @@
1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenModels.swift; sourceTree = "<group>"; };
15A657D96779D1DEB8EF1327 /* CreateRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModel.swift; sourceTree = "<group>"; };
16037EE9E9A52AF37B7818E3 /* AnalyticsSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenUITests.swift; sourceTree = "<group>"; };
16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = "<group>"; };
16DC8C5B2991724903F1FA6A /* AppIcon.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = AppIcon.pdf; sourceTree = "<group>"; };
1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = "<group>"; };
1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = "<group>"; };
@ -754,6 +766,7 @@
1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = "<group>"; };
1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = "<group>"; };
1DF2717AB91060260E5F4781 /* OnboardingPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPageView.swift; sourceTree = "<group>"; };
1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelProtocol.swift; sourceTree = "<group>"; };
1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DTHTMLElement+AttributedStringBuilder.swift"; sourceTree = "<group>"; };
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = "<group>"; };
1FD51B4D5173F7FC886F5360 /* NoticeRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItemContent.swift; sourceTree = "<group>"; };
@ -894,6 +907,7 @@
6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = "<group>"; };
63E1FF2DA52B1DE7CAEC5422 /* AnalyticsPromptScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenViewModel.swift; sourceTree = "<group>"; };
63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridableAvatarImage.swift; sourceTree = "<group>"; };
6493AC9979CEB1410302BFE3 /* RoomDetailsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenCoordinator.swift; sourceTree = "<group>"; };
64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerScreenCoordinator.swift; sourceTree = "<group>"; };
653610CB5F9776EAAAB98155 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fr; path = fr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@ -1165,6 +1179,7 @@
D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServerSelectionScreenState.swift; sourceTree = "<group>"; };
D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = "<group>"; };
DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = "<group>"; };
DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = "<group>"; };
DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsKeys.swift; sourceTree = "<group>"; };
DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = "<group>"; };
DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = "<group>"; };
@ -1183,6 +1198,7 @@
E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
E39CCFA7537FAD50386FDA00 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = "<group>"; };
E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = "<group>"; };
E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = "<group>"; };
E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorModalView.swift; sourceTree = "<group>"; };
E51E3D86A84341C3A0CB8A40 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = "<group>"; };
E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
5F6CB68B44F6C587E463A934 /* View */ = {
isa = PBXGroup;
children = (
DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */,
);
path = View;
sourceTree = "<group>";
};
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 = "<group>";
};
E71742A824A7192C8D378875 /* RoomDetailsEditScreen */ = {
isa = PBXGroup;
children = (
0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */,
16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */,
E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */,
1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */,
5F6CB68B44F6C587E463A934 /* View */,
);
path = RoomDetailsEditScreen;
sourceTree = "<group>";
};
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 */,

View File

@ -69,6 +69,7 @@
"common_file" = "File";
"common_gif" = "GIF";
"common_image" = "Image";
"common_invite_unknown_profile" = "We cant validate this users 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 cant validate this users Matrix ID. The invite might not be received.";
"session_verification_banner_message" = "Looks like youre using a new device. Verify its you to access your encrypted messages.";
"session_verification_banner_title" = "Access your message history";
"settings_rageshake" = "Rageshake";

View File

@ -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
}

View File

@ -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 cant validate this users 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 cant validate this users Matrix ID. The invite might not be received.
public static var screenStartChatUnknownProfile: String { return L10n.tr("Localizable", "screen_start_chat_unknown_profile") }
/// Looks like youre using a new device. Verify its you to access your encrypted messages.
public static var sessionVerificationBannerMessage: String { return L10n.tr("Localizable", "session_verification_banner_message") }
/// Access your message history

View File

@ -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<TimelineDiff, Never> {
get { return underlyingUpdatesPublisher }
set(value) { underlyingUpdatesPublisher = value }
}
var underlyingUpdatesPublisher: AnyPublisher<TimelineDiff, Never>!
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<TimelineDiff, Never> {
get { return underlyingUpdatesPublisher }
set(value) { underlyingUpdatesPublisher = value }
}
var underlyingUpdatesPublisher: AnyPublisher<TimelineDiff, Never>!
//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<Void, RoomProxyError>!
var setNameClosure: ((String?) async -> Result<Void, RoomProxyError>)?
func setName(_ name: String?) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var setTopicClosure: ((String) async -> Result<Void, RoomProxyError>)?
func setTopic(_ topic: String) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var removeAvatarClosure: (() async -> Result<Void, RoomProxyError>)?
func removeAvatar() async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var uploadAvatarMediaClosure: ((MediaInfo) async -> Result<Void, RoomProxyError>)?
func uploadAvatar(media: MediaInfo) async -> Result<Void, RoomProxyError> {
uploadAvatarMediaCallsCount += 1
uploadAvatarMediaReceivedMedia = media
uploadAvatarMediaReceivedInvocations.append(media)
if let uploadAvatarMediaClosure = uploadAvatarMediaClosure {
return await uploadAvatarMediaClosure(media)
} else {
return uploadAvatarMediaReturnValue
}
}
}
class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol {
var callbacks: PassthroughSubject<SessionVerificationControllerProxyCallback, Never> {

View File

@ -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) }))
}
}

View File

@ -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(()) }
}
}

View File

@ -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"

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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<UUID>?
var alertInfo: AlertInfo<UUID>? {
didSet {
alertInfoPublisher.send(alertInfo)
}
}
let alertInfoPublisher: PassthroughSubject<AlertInfo<UUID>?, Never> = .init()
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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<RoomDetailsEditScreenCoordinatorAction, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()
var actions: AnyPublisher<RoomDetailsEditScreenCoordinatorAction, Never> {
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)
}
}

View File

@ -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
}

View File

@ -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<RoomDetailsEditScreenViewState, RoomDetailsEditScreenViewAction>
class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDetailsEditScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<RoomDetailsEditScreenViewModelAction, Never> = .init()
private let roomProxy: RoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let mediaPreprocessor: MediaUploadingPreprocessor = .init()
var actions: AnyPublisher<RoomDetailsEditScreenViewModelAction, Never> {
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)
}
}
}
}

View File

@ -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<RoomDetailsEditScreenViewModelAction, Never> { get }
var context: RoomDetailsEditScreenViewModelType.Context { get }
func didSelectMediaUrl(url: URL)
}

View File

@ -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)
}
}
}

View File

@ -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 }) {

View File

@ -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

View File

@ -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<Void, Never>?
@ -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

View File

@ -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)
}

View File

@ -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 {

View File

@ -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<Void, RoomMemberProxyError> {
sendAccountDataEventBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)

View File

@ -37,6 +37,7 @@ protocol RoomMemberProxyProtocol {
func ignoreUser() async -> Result<Void, RoomMemberProxyError>
func unignoreUser() async -> Result<Void, RoomMemberProxyError>
func canSendStateEvent(type: StateEventType) -> Bool
}
extension RoomMemberProxyProtocol {

View File

@ -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<URL?, RoomProxyError> {
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<Void, RoomProxyError> {
await Task.dispatch(on: .global()) {
do {
return try .success(self.room.setName(name: name))
} catch {
return .failure(.failedSettingRoomName)
}
}
}
func setTopic(_ topic: String) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError> {
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<Void, RoomProxyError> {
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

View File

@ -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<Void, RoomProxyError>
var invitedMembersCount: UInt { get }
func setName(_ name: String?) async -> Result<Void, RoomProxyError>
var joinedMembersCount: UInt { get }
func setTopic(_ topic: String) async -> Result<Void, RoomProxyError>
var activeMembersCount: UInt { get }
func removeAvatar() async -> Result<Void, RoomProxyError>
func uploadAvatar(media: MediaInfo) async -> Result<Void, RoomProxyError>
}
extension RoomProxyProtocol {

View File

@ -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]

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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()

View File

@ -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)
}

View File

@ -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)
}
}