From fe711a9a40b3c4846f590be8b76b35af868197f1 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 15 Sep 2023 16:20:33 +0300 Subject: [PATCH] Change user display name and avatar (#1718) * Fixes #1715, #1716 - Add new user profile editing screen * Bump SDK, adopt new `client.removeAvatar()` method * Address PR review comments --- ElementX.xcodeproj/project.pbxproj | 38 ++++- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../en.lproj/Localizable.strings | 8 +- .../Sources/Application/AppCoordinator.swift | 7 +- ElementX/Sources/Generated/Strings.swift | 14 +- .../Mocks/Generated/SDKGeneratedMocks.swift | 16 ++ .../HomeScreen/HomeScreenViewModel.swift | 15 +- .../SettingsScreenCoordinator.swift | 11 ++ .../SettingsScreen/SettingsScreenModels.swift | 2 + .../SettingsScreenViewModel.swift | 17 +- .../SettingsScreen/View/SettingsScreen.swift | 48 +++--- .../UserDetailsEditScreenCoordinator.swift | 86 ++++++++++ .../UserDetailsEditScreenModels.swift | 70 ++++++++ .../UserDetailsEditScreenViewModel.swift | 151 ++++++++++++++++++ ...erDetailsEditScreenViewModelProtocol.swift | 26 +++ .../View/UserDetailsEditScreen.swift | 129 +++++++++++++++ .../Sources/Services/Client/ClientProxy.swift | 78 +++++++-- .../Services/Client/ClientProxyProtocol.swift | 19 ++- .../Services/Client/MockClientProxy.swift | 26 ++- project.yml | 2 +- 20 files changed, 700 insertions(+), 69 deletions(-) create mode 100644 ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift create mode 100644 ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenModels.swift create mode 100644 ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModel.swift create mode 100644 ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/Settings/UserDetailsEditScreen/View/UserDetailsEditScreen.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 71087608e..b2f77e6f2 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 09C83DDDB07C28364F325209 /* MockRoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */; }; 0A194F5E70B5A628C1BF4476 /* AdvancedSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */; }; 0AA0477E063E72B786A983CF /* AnalyticsPromptScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF2DA52B1DE7CAEC5422 /* AnalyticsPromptScreenViewModel.swift */; }; + 0ACAA31FD0399CEEBA3ECC21 /* UserDetailsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */; }; 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */; }; 0B57C2399B9E1CE5CE0D8005 /* ComposerToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D121B4FCFC38DBCC17BCC6D6 /* ComposerToolbar.swift */; }; 0BDA19079FD6E17C5AC62E22 /* RoomDetailsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */; }; @@ -174,6 +175,7 @@ 38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; }; 38896D54D6D675534E606195 /* RoomTimelineControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */; }; 388D39ED9FE1122EA6D76BF2 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BC84BA0AF11C2128D58ABD /* Common.swift */; }; + 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */; }; 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; }; 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; }; 3A5BD701D1AC916AC534F52C /* OnboardingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB26F24164E9461B2054D0B3 /* OnboardingScreenModels.swift */; }; @@ -228,6 +230,7 @@ 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */; }; 4BAB8222DBA0B4207D1223E0 /* NotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */; }; 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */; }; + 4BB51476A29E7E27BC14EA22 /* UserDetailsEditScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */; }; 4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */; }; 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */; }; 4E8F17EBA24FBBA6ABB62ECB /* MockBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3948D16F021DFDB2CD26EAA8 /* MockBackgroundTaskService.swift */; }; @@ -376,6 +379,7 @@ 7C6376192F578E0BA801BFEC /* AnalyticsSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */; }; 7CD16990BA843BE9ED639129 /* ImageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */; }; 7CFCC177F0ED083867FAD9C9 /* OnboardingScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E727F7E0BCE8A0BBFD33FF /* OnboardingScreenCoordinator.swift */; }; + 7D58B4F46CAA9A7C3E4C6A30 /* UserDetailsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */; }; 7E2BB42805C59DB57E95610F /* PillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7773CBFDBD458E0B7E270507 /* PillView.swift */; }; 7E91BAC17963ED41208F489B /* UserSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8BDC092D817B68CD9040C5 /* UserSessionStore.swift */; }; 7ECF12D5DCD69F67BD3E3842 /* RoomTimelineControllerFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FE0CDF1FFA92EA7EE17B0B /* RoomTimelineControllerFactoryProtocol.swift */; }; @@ -520,6 +524,7 @@ A5B9EF45C7B8ACEB4954AE36 /* LoginScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9780389F8A53E4D26E23DD03 /* LoginScreenViewModelProtocol.swift */; }; A5C5C18671EDD2747AC16D2D /* OnboardingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C1CEBA9BCF5D2AD1884FA /* OnboardingScreenViewModel.swift */; }; A5D551E5691749066E0E0C44 /* RoomDetailsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B440C4705E4B899BCB899 /* RoomDetailsScreenViewModel.swift */; }; + A64B52D9F73F9A6B95AF24FE /* UserDetailsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CD503F5E0938FE53C7C6E7 /* UserDetailsEditScreenCoordinator.swift */; }; A680F54935A6ADEA4ED6C38F /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4C9547BBFEEF30AA11329B /* TimelineItemStatusView.swift */; }; A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; }; A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; @@ -868,6 +873,7 @@ 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.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 = ""; }; + 022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModel.swift; 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 = ""; }; 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenViewModel.swift; sourceTree = ""; }; @@ -988,6 +994,7 @@ 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; 27B8315A340B46F98B9C5AF0 /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; + 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenModels.swift; sourceTree = ""; }; 27EA0F71A3A400A202E15318 /* CreatePollScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreen.swift; sourceTree = ""; }; 287FC98AF2664EAD79C0D902 /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -1245,6 +1252,7 @@ 84816E0D2F34E368BF64FA60 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; 84A87D0471D438A233C2CF4A /* RoomMemberDetailsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModel.swift; sourceTree = ""; }; 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTaskRunnerTests.swift; sourceTree = ""; }; + 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModelProtocol.swift; sourceTree = ""; }; 851EF6258DF8B7EF129DC3AC /* WelcomeScreenScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenViewModelTests.swift; sourceTree = ""; }; 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelTests.swift; sourceTree = ""; }; 854BCEAF2A832176FAACD2CB /* SplashScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenCoordinator.swift; sourceTree = ""; }; @@ -1252,6 +1260,7 @@ 86873A768B13069BB5CAECF6 /* InvitesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenViewModelProtocol.swift; sourceTree = ""; }; 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModel.swift; sourceTree = ""; }; 874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; + 88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreen.swift; sourceTree = ""; }; 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = ""; }; 8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreen.swift; sourceTree = ""; }; 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; @@ -1431,6 +1440,7 @@ C352359663A0E52BA20761EE /* LoadableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableImage.swift; sourceTree = ""; }; C49C1CEBA9BCF5D2AD1884FA /* OnboardingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreenViewModel.swift; sourceTree = ""; }; C4C89820BB2B88D4EA28131C /* BugReportScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelProtocol.swift; sourceTree = ""; }; + C4CD503F5E0938FE53C7C6E7 /* UserDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenCoordinator.swift; sourceTree = ""; }; C54464351F170D570110AFCA /* WelcomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreen.swift; sourceTree = ""; }; C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = ""; }; C5B7A755E985FA14469E86B2 /* RoomMembersListScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenUITests.swift; sourceTree = ""; }; @@ -2267,6 +2277,18 @@ path = Extensions; sourceTree = ""; }; + 4775A7D6FBB210BF21318AD9 /* UserDetailsEditScreen */ = { + isa = PBXGroup; + children = ( + C4CD503F5E0938FE53C7C6E7 /* UserDetailsEditScreenCoordinator.swift */, + 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */, + 022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */, + 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */, + 7AE042B6E4318E352DD3991A /* View */, + ); + path = UserDetailsEditScreen; + sourceTree = ""; + }; 490F49F5627FBEF3BB8665A3 /* SimpleScreenExample */ = { isa = PBXGroup; children = ( @@ -2572,6 +2594,7 @@ 441115752CA2408F26A72D11 /* NotificationSettingsEditScreen */, 7B91CB64534AD870924CCFEF /* NotificationSettingsScreen */, B364E08924AD15820350CDD9 /* SettingsScreen */, + 4775A7D6FBB210BF21318AD9 /* UserDetailsEditScreen */, ); path = Settings; sourceTree = ""; @@ -2797,6 +2820,14 @@ path = Media; sourceTree = ""; }; + 7AE042B6E4318E352DD3991A /* View */ = { + isa = PBXGroup; + children = ( + 88410BD213FDF9B28E8B671F /* UserDetailsEditScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 7B14834450AE76EEFDDBCBB8 /* View */ = { isa = PBXGroup; children = ( @@ -4963,6 +4994,11 @@ 34C752A73717C691582DC6C7 /* UnsupportedRoomTimelineItem.swift in Sources */, E1F446C6B78A3A0FEA15079C /* UnsupportedRoomTimelineView.swift in Sources */, 7A71AEF419904209BB8C2833 /* UserAgentBuilder.swift in Sources */, + 7D58B4F46CAA9A7C3E4C6A30 /* UserDetailsEditScreen.swift in Sources */, + A64B52D9F73F9A6B95AF24FE /* UserDetailsEditScreenCoordinator.swift in Sources */, + 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */, + 4BB51476A29E7E27BC14EA22 /* UserDetailsEditScreenViewModel.swift in Sources */, + 0ACAA31FD0399CEEBA3ECC21 /* UserDetailsEditScreenViewModelProtocol.swift in Sources */, 828EA5009557C2B9DCD4CA0F /* UserDiscoverySection.swift in Sources */, 044DD8F80231BC30570F7965 /* UserDiscoveryService.swift in Sources */, 1C409A26A99F0371C47AFA51 /* UserDiscoveryServiceProtocol.swift in Sources */, @@ -5657,7 +5693,7 @@ repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 1.1.14; + version = 1.1.15; }; }; 821C67C9A7F8CC3FD41B28B4 /* XCRemoteSwiftPackageReference "emojibase-bindings" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f42d6e7b0..dcc3aa014 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -129,8 +129,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-rust-components-swift", "state" : { - "revision" : "db9a4c6eddc385c62695afdf7562827c7a586e72", - "version" : "1.1.14" + "revision" : "aac4f78107e1926f09ee18cd0f602332e73d0f81", + "version" : "1.1.15" } }, { @@ -217,7 +217,7 @@ { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/SwiftUI-Introspect", + "location" : "https://github.com/siteline/SwiftUI-Introspect.git", "state" : { "revision" : "b94da693e57eaf79d16464b8b7c90d09cba4e290", "version" : "0.9.2" diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index ba929bb2e..bb731f67e 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -207,6 +207,7 @@ "rich_text_editor_numbered_list" = "Toggle numbered list"; "rich_text_editor_open_compose_options" = "Open compose options"; "rich_text_editor_quote" = "Toggle quote"; +"rich_text_editor_remove_link" = "Remove link"; "rich_text_editor_unindent" = "Unindent"; "rich_text_editor_url_placeholder" = "Link"; "rich_text_editor_a11y_add_attachment" = "Add attachment"; @@ -252,7 +253,7 @@ "screen_change_server_subtitle" = "What is the address of your server?"; "screen_create_poll_add_option_btn" = "Add option"; "screen_create_poll_anonymous_desc" = "Show results only after poll ends"; -"screen_create_poll_anonymous_headline" = "Anonymous Poll"; +"screen_create_poll_anonymous_headline" = "Hide votes"; "screen_create_poll_answer_hint" = "Option %1$d"; "screen_create_poll_discard_confirmation" = "Are you sure you want to discard this poll?"; "screen_create_poll_discard_confirmation_title" = "Discard Poll"; @@ -269,6 +270,11 @@ "screen_create_room_public_option_title" = "Public room (anyone)"; "screen_create_room_room_name_label" = "Room name"; "screen_create_room_topic_label" = "Topic (optional)"; +"screen_edit_profile_display_name" = "Display name"; +"screen_edit_profile_display_name_placeholder" = "Your display name"; +"screen_edit_profile_error" = "An unknown error was encountered and the information couldn't be changed."; +"screen_edit_profile_error_title" = "Unable to update profile"; +"screen_edit_profile_updating_details" = "Updating profile…"; "screen_invites_decline_chat_message" = "Are you sure you want to decline the invitation to join %1$@?"; "screen_invites_decline_chat_title" = "Decline invite"; "screen_invites_decline_direct_chat_message" = "Are you sure you want to decline this private chat with %1$@?"; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index c2bd9a507..ef95444bc 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -356,14 +356,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, } Task { - var displayName = "" - if case .success(let name) = await userSession.clientProxy.loadUserDisplayName() { - displayName = name - } - let credentials = SoftLogoutScreenCredentials(userID: userSession.userID, homeserverName: userSession.homeserver, - userDisplayName: displayName, + userDisplayName: userSession.clientProxy.userDisplayName.value ?? "", deviceID: userSession.deviceID) let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore, appSettings: appSettings) diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 63ef9602f..23943892d 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -510,6 +510,8 @@ public enum L10n { public static var richTextEditorOpenComposeOptions: String { return L10n.tr("Localizable", "rich_text_editor_open_compose_options") } /// Toggle quote public static var richTextEditorQuote: String { return L10n.tr("Localizable", "rich_text_editor_quote") } + /// Remove link + public static var richTextEditorRemoveLink: String { return L10n.tr("Localizable", "rich_text_editor_remove_link") } /// Unindent public static var richTextEditorUnindent: String { return L10n.tr("Localizable", "rich_text_editor_unindent") } /// Link @@ -632,7 +634,7 @@ public enum L10n { public static var screenCreatePollAddOptionBtn: String { return L10n.tr("Localizable", "screen_create_poll_add_option_btn") } /// Show results only after poll ends public static var screenCreatePollAnonymousDesc: String { return L10n.tr("Localizable", "screen_create_poll_anonymous_desc") } - /// Anonymous Poll + /// Hide votes public static var screenCreatePollAnonymousHeadline: String { return L10n.tr("Localizable", "screen_create_poll_anonymous_headline") } /// Option %1$d public static func screenCreatePollAnswerHint(_ p1: Int) -> String { @@ -682,6 +684,16 @@ public enum L10n { public static var screenDmDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_dm_details_unblock_alert_description") } /// Unblock user public static var screenDmDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_dm_details_unblock_user") } + /// Display name + public static var screenEditProfileDisplayName: String { return L10n.tr("Localizable", "screen_edit_profile_display_name") } + /// Your display name + public static var screenEditProfileDisplayNamePlaceholder: String { return L10n.tr("Localizable", "screen_edit_profile_display_name_placeholder") } + /// An unknown error was encountered and the information couldn't be changed. + public static var screenEditProfileError: String { return L10n.tr("Localizable", "screen_edit_profile_error") } + /// Unable to update profile + public static var screenEditProfileErrorTitle: String { return L10n.tr("Localizable", "screen_edit_profile_error_title") } + /// Updating profile… + public static var screenEditProfileUpdatingDetails: String { return L10n.tr("Localizable", "screen_edit_profile_updating_details") } /// Are you sure you want to decline the invitation to join %1$@? public static func screenInvitesDeclineChatMessage(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_invites_decline_chat_message", String(describing: p1)) diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index d0b8d0602..7aeeab160 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -431,6 +431,22 @@ class SDKClientMock: SDKClientProtocol { return notificationClientProcessSetupReturnValue } } + //MARK: - removeAvatar + + public var removeAvatarThrowableError: Error? + public var removeAvatarCallsCount = 0 + public var removeAvatarCalled: Bool { + return removeAvatarCallsCount > 0 + } + public var removeAvatarClosure: (() throws -> Void)? + + public func removeAvatar() throws { + if let error = removeAvatarThrowableError { + throw error + } + removeAvatarCallsCount += 1 + try removeAvatarClosure?() + } //MARK: - restoreSession public var restoreSessionSessionThrowableError: Error? diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index e352f9b78..bed9b4c49 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -70,10 +70,16 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol } .store(in: &cancellables) - userSession.clientProxy.avatarURLPublisher + userSession.clientProxy.userAvatarURL + .receive(on: DispatchQueue.main) .weakAssign(to: \.state.userAvatarURL, on: self) .store(in: &cancellables) + userSession.clientProxy.userDisplayName + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.userDisplayName, on: self) + .store(in: &cancellables) + selectedRoomPublisher .weakAssign(to: \.state.selectedRoomID, on: self) .store(in: &cancellables) @@ -182,12 +188,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol if roomListMode == .rooms { Task { await self.userSession.clientProxy.loadUserAvatarURL() - } - - Task { - if case let .success(userDisplayName) = await self.userSession.clientProxy.loadUserDisplayName() { - self.state.userDisplayName = userDisplayName - } + await self.userSession.clientProxy.loadUserDisplayName() } } } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index 08f97940f..ff114a08a 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -57,6 +57,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { switch action { case .close: actionsSubject.send(.dismiss) + case .userDetails: + presentUserDetailsEditScreen() case .accountProfile: presentAccountProfileURL() case .analytics: @@ -121,6 +123,15 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { // MARK: - Private + private func presentUserDetailsEditScreen() { + let coordinator = UserDetailsEditScreenCoordinator(parameters: .init(clientProxy: parameters.userSession.clientProxy, + mediaProvider: parameters.userSession.mediaProvider, + navigationStackCoordinator: parameters.navigationStackCoordinator, + userIndicatorController: parameters.userIndicatorController)) + + parameters.navigationStackCoordinator?.push(coordinator) + } + private func presentAnalyticsScreen() { let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics)) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index 5f10f12a9..d53a12ca0 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -19,6 +19,7 @@ import UIKit enum SettingsScreenViewModelAction { case close + case userDetails case accountProfile case analytics case reportBug @@ -47,6 +48,7 @@ struct SettingsScreenViewState: BindableState { enum SettingsScreenViewAction { case close + case userDetails case accountProfile case analytics case reportBug diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index b7fe2986e..b52f00c57 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -46,18 +46,19 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo showDeveloperOptions: appSettings.canShowDeveloperOptions), imageProvider: userSession.mediaProvider) - userSession.clientProxy.avatarURLPublisher + userSession.clientProxy.userAvatarURL + .receive(on: DispatchQueue.main) .weakAssign(to: \.state.userAvatarURL, on: self) .store(in: &cancellables) + + userSession.clientProxy.userDisplayName + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.userDisplayName, on: self) + .store(in: &cancellables) Task { await userSession.clientProxy.loadUserAvatarURL() - } - - Task { - if case let .success(userDisplayName) = await self.userSession.clientProxy.loadUserDisplayName() { - state.userDisplayName = userDisplayName - } + await userSession.clientProxy.loadUserDisplayName() } userSession.callbacks @@ -79,6 +80,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo switch viewAction { case .close: actionsSubject.send(.close) + case .userDetails: + actionsSubject.send(.userDetails) case .accountProfile: actionsSubject.send(.accountProfile) case .analytics: diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index d83ed9bdb..1ee8293d6 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -49,30 +49,40 @@ struct SettingsScreen: View { context.send(viewAction: .updateWindow(window)) } } - + private var userSection: some View { Section { ListRow(kind: .custom { - HStack(spacing: 12) { - LoadableAvatarImage(url: context.viewState.userAvatarURL, - name: context.viewState.userDisplayName, - contentID: context.viewState.userID, - avatarSize: .user(on: .settings), - imageProvider: context.imageProvider) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - Text(context.viewState.userDisplayName ?? "") - .font(.compound.headingMD) - .foregroundColor(.compound.textPrimary) - Text(context.viewState.userID) - .font(.compound.bodySM) - .foregroundColor(.compound.textSecondary) + Button { + context.send(viewAction: .userDetails) + } label: { + HStack(spacing: 12) { + LoadableAvatarImage(url: context.viewState.userAvatarURL, + name: context.viewState.userDisplayName, + contentID: context.viewState.userID, + avatarSize: .user(on: .settings), + imageProvider: context.imageProvider) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(context.viewState.userDisplayName ?? "") + .font(.compound.headingMD) + .foregroundColor(.compound.textPrimary) + Text(context.viewState.userID) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + } + + Spacer() + + CompoundIcon(\.chevronRight) + .font(.system(size: 24)) + .foregroundColor(.compound.iconTertiaryAlpha) + .flipsForRightToLeftLayoutDirection(true) } - .accessibilityElement(children: .combine) + .padding(.horizontal, ListRowPadding.horizontal) + .padding(.vertical, 8) } - .padding(.horizontal, ListRowPadding.horizontal) - .padding(.vertical, 8) }) } } diff --git a/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift new file mode 100644 index 000000000..041be04d0 --- /dev/null +++ b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift @@ -0,0 +1,86 @@ +// +// 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 UserDetailsEditScreenCoordinatorParameters { + let clientProxy: ClientProxyProtocol + let mediaProvider: MediaProviderProtocol + weak var navigationStackCoordinator: NavigationStackCoordinator? + weak var userIndicatorController: UserIndicatorControllerProtocol? +} + +enum UserDetailsEditScreenCoordinatorAction { } + +final class UserDetailsEditScreenCoordinator: CoordinatorProtocol { + private let parameters: UserDetailsEditScreenCoordinatorParameters + private var viewModel: UserDetailsEditScreenViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables = Set() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: UserDetailsEditScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = UserDetailsEditScreenViewModel(clientProxy: parameters.clientProxy, + mediaProvider: parameters.mediaProvider, + userIndicatorController: parameters.userIndicatorController) + } + + func start() { + viewModel.actions + .sink { [weak self] action in + switch action { + case .displayCameraPicker: + self?.displayMediaPickerWithSource(.camera) + case .displayMediaPicker: + self?.displayMediaPickerWithSource(.photoLibrary) + case .displayFilePicker: + self?.displayMediaPickerWithSource(.documents) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(UserDetailsEditScreen(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/Settings/UserDetailsEditScreen/UserDetailsEditScreenModels.swift b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenModels.swift new file mode 100644 index 000000000..a0d746b5a --- /dev/null +++ b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenModels.swift @@ -0,0 +1,70 @@ +// +// 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 UserDetailsEditScreenViewModelAction { + case displayCameraPicker + case displayMediaPicker + case displayFilePicker +} + +struct UserDetailsEditScreenViewState: BindableState { + let userID: String + + var currentAvatarURL: URL? + var selectedAvatarURL: URL? + + var currentDisplayName: String? + + var localMedia: MediaInfo? + + var bindings: UserDetailsEditScreenViewStateBindings + + var nameDidChange: Bool { + bindings.name != currentDisplayName + } + + /// The string shown for the room's name when it can't be edited. + var nameRowTitle: String { + bindings.name.isEmpty ? L10n.screenEditProfileDisplayName : bindings.name + } + + var avatarDidChange: Bool { + localMedia != nil || selectedAvatarURL != currentAvatarURL + } + + var canSave: Bool { + !bindings.name.isEmpty && (avatarDidChange || nameDidChange) + } + + var showDeleteImageAction: Bool { + localMedia != nil || selectedAvatarURL != nil + } +} + +struct UserDetailsEditScreenViewStateBindings { + var name = "" + var showMediaSheet = false +} + +enum UserDetailsEditScreenViewAction { + case save + case presentMediaSource + case displayCameraPicker + case displayMediaPicker + case removeImage +} diff --git a/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModel.swift b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModel.swift new file mode 100644 index 000000000..d45a9fa75 --- /dev/null +++ b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModel.swift @@ -0,0 +1,151 @@ +// +// 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 UserDetailsEditScreenViewModelType = StateStoreViewModel + +class UserDetailsEditScreenViewModel: UserDetailsEditScreenViewModelType, UserDetailsEditScreenViewModelProtocol { + private let actionsSubject: PassthroughSubject = .init() + private let clientProxy: ClientProxyProtocol + private weak var userIndicatorController: UserIndicatorControllerProtocol? + private let mediaPreprocessor: MediaUploadingPreprocessor = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(clientProxy: ClientProxyProtocol, + mediaProvider: MediaProviderProtocol, + userIndicatorController: UserIndicatorControllerProtocol?) { + self.clientProxy = clientProxy + self.userIndicatorController = userIndicatorController + + super.init(initialViewState: UserDetailsEditScreenViewState(userID: clientProxy.userID, + bindings: .init()), imageProvider: mediaProvider) + + clientProxy.userAvatarURL + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.currentAvatarURL, on: self) + .store(in: &cancellables) + + clientProxy.userAvatarURL + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.selectedAvatarURL, on: self) + .store(in: &cancellables) + + clientProxy.userDisplayName + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.currentDisplayName, on: self) + .store(in: &cancellables) + + clientProxy.userDisplayName + .receive(on: DispatchQueue.main) + .sink { [weak self] displayName in + guard let self else { return } + + state.bindings.name = displayName ?? "" + } + .store(in: &cancellables) + + Task { + await self.clientProxy.loadUserAvatarURL() + await self.clientProxy.loadUserDisplayName() + } + } + + // MARK: - Public + + override func process(viewAction: UserDetailsEditScreenViewAction) { + switch viewAction { + case .save: + saveUserDetails() + case .presentMediaSource: + state.bindings.showMediaSheet = true + case .displayCameraPicker: + actionsSubject.send(.displayCameraPicker) + case .displayMediaPicker: + actionsSubject.send(.displayMediaPicker) + case .removeImage: + state.localMedia = nil + state.selectedAvatarURL = nil + } + } + + func didSelectMediaURL(url: URL) { + Task { + let userIndicatorID = UUID().uuidString + defer { + userIndicatorController?.retractIndicatorWithId(userIndicatorID) + } + userIndicatorController?.submitIndicator(UserIndicator(id: userIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + 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 saveUserDetails() { + Task { + let userIndicatorID = UUID().uuidString + defer { + userIndicatorController?.retractIndicatorWithId(userIndicatorID) + } + userIndicatorController?.submitIndicator(UserIndicator(id: userIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.screenEditProfileUpdatingDetails, + 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.clientProxy.setUserAvatar(media: localMedia).get() + } else if await self.state.selectedAvatarURL == nil { + try await self.clientProxy.removeUserAvatar().get() + } + } + } + + if state.nameDidChange { + group.addTask { + try await self.clientProxy.setUserDisplayName(self.state.bindings.name).get() + } + } + + try await group.waitForAll() + } + } catch { + userIndicatorController?.alertInfo = .init(id: .init(), + title: L10n.screenEditProfileErrorTitle, + message: L10n.screenEditProfileError) + } + } + } +} diff --git a/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModelProtocol.swift b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModelProtocol.swift new file mode 100644 index 000000000..bfeefd9c9 --- /dev/null +++ b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenViewModelProtocol.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 UserDetailsEditScreenViewModelProtocol { + var actions: AnyPublisher { get } + var context: UserDetailsEditScreenViewModelType.Context { get } + + func didSelectMediaURL(url: URL) +} diff --git a/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/View/UserDetailsEditScreen.swift b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/View/UserDetailsEditScreen.swift new file mode 100644 index 000000000..3810c5daa --- /dev/null +++ b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/View/UserDetailsEditScreen.swift @@ -0,0 +1,129 @@ +// +// 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 Compound +import SwiftUI + +struct UserDetailsEditScreen: View { + @ObservedObject var context: UserDetailsEditScreenViewModel.Context + @FocusState private var focus: Bool + + var body: some View { + Form { + avatar + nameSection + } + .compoundList() + .scrollDismissesKeyboard(.immediately) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + } + + // MARK: - Private + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.actionSave) { + context.send(viewAction: .save) + focus = false + } + .disabled(!context.viewState.canSave) + } + } + + private var avatar: some View { + Button { + context.send(viewAction: .presentMediaSource) + } label: { + OverridableAvatarImage(overrideURL: context.viewState.localMedia?.thumbnailURL, + url: context.viewState.selectedAvatarURL, + name: context.viewState.currentDisplayName, + contentID: context.viewState.userID, + avatarSize: .user(on: .memberDetails), + imageProvider: context.imageProvider) + .overlay(alignment: .bottomTrailing) { + avatarOverlayIcon + } + .confirmationDialog("", isPresented: $context.showMediaSheet) { + mediaActionSheet + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + } + + private var nameSection: some View { + Section { + ListRow(label: .plain(title: L10n.screenEditProfileDisplayNamePlaceholder), + kind: .textField(text: $context.name, axis: .horizontal)) + .focused($focus) + } header: { + Text(L10n.screenEditProfileDisplayName) + .compoundListSectionHeader() + } + } + + private var avatarOverlayIcon: some View { + Image(systemName: "camera") + .font(.system(size: 14, weight: .semibold)) + .padding(3) + .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 UserDetailsEditScreen_Previews: PreviewProvider { + static let viewModel = UserDetailsEditScreenViewModel(clientProxy: MockClientProxy(userID: "@stefan:matrix.org"), + mediaProvider: MockMediaProvider(), + userIndicatorController: UserIndicatorControllerMock.default) + + static var previews: some View { + NavigationStack { + UserDetailsEditScreen(context: viewModel.context) + } + } +} diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 84678a6d2..09d0c8243 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -43,12 +43,14 @@ class ClientProxy: ClientProxyProtocol { private let roomListRecencyOrderingAllowedEventTypes = ["m.room.message", "m.room.encrypted", "m.sticker"] private var loadCachedAvatarURLTask: Task? - private let avatarURLSubject = CurrentValueSubject(nil) - var avatarURLPublisher: AnyPublisher { - avatarURLSubject - .removeDuplicates() - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + private let userAvatarURLSubject = CurrentValueSubject(nil) + var userAvatarURL: CurrentValuePublisher { + userAvatarURLSubject.asCurrentValuePublisher() + } + + private let userDisplayNameSubject = CurrentValueSubject(nil) + var userDisplayName: CurrentValuePublisher { + userDisplayNameSubject.asCurrentValuePublisher() } private var cancellables = Set() @@ -279,26 +281,74 @@ class ClientProxy: ClientProxyProtocol { backgroundTaskService: backgroundTaskService) } - func loadUserDisplayName() async -> Result { + func loadUserDisplayName() async -> Result { await Task.dispatch(on: clientQueue) { do { let displayName = try self.client.displayName() - return .success(displayName) + self.userDisplayNameSubject.send(displayName) + return .success(()) } catch { - return .failure(.failedRetrievingDisplayName) + return .failure(.failedRetrievingUserDisplayName) + } + } + } + + func setUserDisplayName(_ name: String) async -> Result { + await Task.dispatch(on: clientQueue) { + do { + try self.client.setDisplayName(name: name) + Task { + await self.loadUserDisplayName() + } + return .success(()) + } catch { + return .failure(.failedSettingUserDisplayName) } } } - func loadUserAvatarURL() async { + func loadUserAvatarURL() async -> Result { await Task.dispatch(on: clientQueue) { do { let urlString = try self.client.avatarUrl() self.loadCachedAvatarURLTask?.cancel() - self.avatarURLSubject.value = urlString.flatMap(URL.init) + self.userAvatarURLSubject.send(urlString.flatMap(URL.init)) + return .success(()) } catch { - MXLog.error("Failed fetching the user avatar url: \(error)") - return + return .failure(.failedRetrievingUserAvatarURL) + } + } + } + + func setUserAvatar(media: MediaInfo) async -> Result { + await Task.dispatch(on: .global()) { + guard case let .image(imageURL, _, _) = media, let mimeType = media.mimeType else { + return .failure(.failedSettingUserAvatar) + } + + do { + let data = try Data(contentsOf: imageURL) + try self.client.uploadAvatar(mimeType: mimeType, data: [UInt8](data)) + Task { + await self.loadUserAvatarURL() + } + return .success(()) + } catch { + return .failure(.failedSettingUserAvatar) + } + } + } + + func removeUserAvatar() async -> Result { + await Task.dispatch(on: .global()) { + do { + try self.client.removeAvatar() + Task { + await self.loadUserAvatarURL() + } + return .success(()) + } catch { + return .failure(.failedSettingUserAvatar) } } } @@ -381,7 +431,7 @@ class ClientProxy: ClientProxyProtocol { } } guard !Task.isCancelled else { return } - self.avatarURLSubject.value = urlString.flatMap(URL.init) + self.userAvatarURLSubject.value = urlString.flatMap(URL.init) } } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index c2481c082..1e4568f85 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -39,7 +39,9 @@ enum ClientProxyLoadingState { enum ClientProxyError: Error { case failedCreatingRoom case failedRetrievingDirectRoom - case failedRetrievingDisplayName + case failedRetrievingUserDisplayName + case failedRetrievingUserAvatarURL + case failedSettingUserDisplayName case failedRetrievingAccountData case failedSettingAccountData case failedRetrievingSessionVerificationController @@ -48,6 +50,7 @@ enum ClientProxyError: Error { case failedUploadingMedia(MatrixErrorCode) case failedSearchingUsers case failedGettingUserProfile + case failedSettingUserAvatar } enum SlidingSyncConstants { @@ -77,8 +80,10 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { var deviceID: String? { get } var homeserver: String { get } + + var userDisplayName: CurrentValuePublisher { get } - var avatarURLPublisher: AnyPublisher { get } + var userAvatarURL: CurrentValuePublisher { get } var restorationToken: RestorationToken? { get } @@ -104,9 +109,15 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? - func loadUserDisplayName() async -> Result + @discardableResult func loadUserDisplayName() async -> Result + + func setUserDisplayName(_ name: String) async -> Result - func loadUserAvatarURL() async + @discardableResult func loadUserAvatarURL() async -> Result + + func setUserAvatar(media: MediaInfo) async -> Result + + func removeUserAvatar() async -> Result func accountDataEvent(type: String) async -> Result diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 8446ddaa7..b0369d3d5 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -32,7 +32,9 @@ class MockClientProxy: ClientProxyProtocol { var inviteSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider() - var avatarURLPublisher: AnyPublisher { Empty().eraseToAnyPublisher() } + var userAvatarURL: CurrentValuePublisher { CurrentValueSubject(nil).asCurrentValuePublisher() } + + var userDisplayName: CurrentValuePublisher { CurrentValueSubject("User display name").asCurrentValuePublisher() } var notificationSettings: NotificationSettingsProxyProtocol = NotificationSettingsProxyMock(with: .init()) @@ -41,8 +43,6 @@ class MockClientProxy: ClientProxyProtocol { self.deviceID = deviceID self.roomSummaryProvider = roomSummaryProvider } - - func loadUserAvatarURL() async { } func startSync() { } @@ -87,8 +87,24 @@ class MockClientProxy: ClientProxyProtocol { } } - func loadUserDisplayName() async -> Result { - .success("User display name") + func loadUserDisplayName() async -> Result { + .failure(.failedRetrievingUserDisplayName) + } + + func setUserDisplayName(_ name: String) async -> Result { + .failure(.failedSettingUserDisplayName) + } + + func loadUserAvatarURL() async -> Result { + .failure(.failedRetrievingUserAvatarURL) + } + + func setUserAvatar(media: MediaInfo) async -> Result { + .failure(.failedSettingUserAvatar) + } + + func removeUserAvatar() async -> Result { + .failure(.failedSettingUserAvatar) } func accountDataEvent(type: String) async -> Result where Content: Decodable { diff --git a/project.yml b/project.yml index 05e1a30ea..380c07ab9 100644 --- a/project.yml +++ b/project.yml @@ -46,7 +46,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/matrix-org/matrix-rust-components-swift - exactVersion: 1.1.14 + exactVersion: 1.1.15 # path: ../matrix-rust-sdk DesignKit: path: DesignKit