diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b9be15ff6..9880d53d9 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; 03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */; }; 03CB204C52F18E24A5C3D219 /* UITestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967873B9E11828B67F64C89A /* UITestsAppCoordinator.swift */; }; + 03D684A3AE85A23B3DA3B43F /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26747B3154A5DBC3A7E24A5 /* Image.swift */; }; 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422724361B6555364C43281E /* RoomHeaderView.swift */; }; 05776B005C57E92582F0CF08 /* BuildSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F87116470221880017CF522 /* BuildSettings.swift */; }; 059173B3C77056C406906B6D /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = D4DA544B2520BFA65D6DB4BB /* target.yml */; }; @@ -757,6 +758,7 @@ E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = ""; }; E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = ""; }; E18CF12478983A5EB390FB26 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; + E26747B3154A5DBC3A7E24A5 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; E3E29F98CF0E960689A410E3 /* SettingsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUITests.swift; sourceTree = ""; }; E45C57120F28F8D619150219 /* sr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sr; path = sr.lproj/Localizable.strings; sourceTree = ""; }; @@ -1074,6 +1076,7 @@ isa = PBXGroup; children = ( B6E89E530A8E92EC44301CA1 /* Bundle.swift */, + E26747B3154A5DBC3A7E24A5 /* Image.swift */, 40B21E611DADDEF00307E7AC /* String.swift */, 227AC5D71A4CE43512062243 /* URL.swift */, ); @@ -2317,6 +2320,7 @@ 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */, DE4F8C4E0F1DB4832F09DE97 /* HomeScreenViewModel.swift in Sources */, 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */, + 03D684A3AE85A23B3DA3B43F /* Image.swift in Sources */, 6EA61FCA55D950BDE326A1A7 /* ImageAnonymizer.swift in Sources */, 2E59008365E01F0AFB3A6B24 /* ImageRoomMessage.swift in Sources */, DDB80FD2753FEAAE43CC2AAE /* ImageRoomTimelineItem.swift in Sources */, diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index bdb3b62f4..5a1476f00 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -234,8 +234,12 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier)) case .presentSettings: self.stateMachine.processEvent(.showSettingsScreen) + case .presentBugReport: + self.presentBugReportScreen() case .verifySession: self.stateMachine.processEvent(.showSessionVerificationScreen) + case .signOut: + self.confirmSignOut() } } @@ -335,6 +339,19 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { navigationRouter.present(alert, animated: true) } + private func confirmSignOut() { + let alert = UIAlertController(title: ElementL10n.actionSignOut, + message: ElementL10n.actionSignOutConfirmationSimple, + preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel)) + alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { [weak self] _ in + self?.stateMachine.processEvent(.attemptSignOut) + }) + + navigationRouter.present(alert, animated: true) + } + private func processScreenshotDetection(image: UIImage?, error: Error?) { MXLog.debug("Detected screenshot: \(String(describing: image)), error: \(String(describing: error))") diff --git a/ElementX/Sources/AppCoordinatorStateMachine.swift b/ElementX/Sources/AppCoordinatorStateMachine.swift index 5be30d3ca..ef7e874b2 100644 --- a/ElementX/Sources/AppCoordinatorStateMachine.swift +++ b/ElementX/Sources/AppCoordinatorStateMachine.swift @@ -92,7 +92,7 @@ class AppCoordinatorStateMachine { machine.addRoutes(event: .succeededRestoringSession, transitions: [.restoringSession => .homeScreen]) machine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) - machine.addRoutes(event: .attemptSignOut, transitions: [.settingsScreen => .signingOut]) + machine.addRoutes(event: .attemptSignOut, transitions: [.any => .signingOut]) machine.addRoutes(event: .succeededSigningOut, transitions: [.signingOut => .signedOut]) machine.addRoutes(event: .failedSigningOut, transitions: [.signingOut => .settingsScreen]) diff --git a/ElementX/Sources/Other/Extensions/Image.swift b/ElementX/Sources/Other/Extensions/Image.swift new file mode 100644 index 000000000..9062327eb --- /dev/null +++ b/ElementX/Sources/Other/Extensions/Image.swift @@ -0,0 +1,22 @@ +// +// 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 + +extension Image { + /// Empty image view + static let empty = Image(uiImage: .init(ciImage: .empty())) +} diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index 3bafa4e3d..ad838b699 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -26,7 +26,9 @@ struct HomeScreenCoordinatorParameters { enum HomeScreenCoordinatorAction { case presentRoom(roomIdentifier: String) case presentSettings + case presentBugReport case verifySession + case signOut } final class HomeScreenCoordinator: Coordinator, Presentable { @@ -53,8 +55,7 @@ final class HomeScreenCoordinator: Coordinator, Presentable { init(parameters: HomeScreenCoordinatorParameters) { self.parameters = parameters - viewModel = HomeScreenViewModel(initialDisplayName: parameters.userSession.userID, - attributedStringBuilder: parameters.attributedStringBuilder) + viewModel = HomeScreenViewModel(attributedStringBuilder: parameters.attributedStringBuilder) let view = HomeScreen(context: viewModel.context) hostingController = UIHostingController(rootView: view) @@ -65,8 +66,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable { switch action { case .selectRoom(let roomIdentifier): self.callback?(.presentRoom(roomIdentifier: roomIdentifier)) - case .tapUserAvatar: - self.callback?(.presentSettings) + case .userMenu(let action): + self.processUserMenuAction(action) case .verifySession: self.callback?(.verifySession) } @@ -100,10 +101,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable { self.viewModel.updateWithUserAvatar(avatar) } } - - if case let .success(userDisplayName) = await parameters.userSession.clientProxy.loadUserDisplayName() { - self.viewModel.updateWithUserDisplayName(userDisplayName) - } } } @@ -136,4 +133,26 @@ final class HomeScreenCoordinator: Coordinator, Presentable { viewModel.updateWithRoomSummaries(roomSummaries) } + + private func processUserMenuAction(_ action: HomeScreenViewUserMenuAction) { + switch action { + case .settings: + callback?(.presentSettings) + case .inviteFriends: + presentInviteFriends() + case .feedback: + callback?(.presentBugReport) + case .signOut: + callback?(.signOut) + } + } + + private func presentInviteFriends() { + guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: parameters.userSession.userID).absoluteString else { + return + } + let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleName, permalink) + let vc = UIActivityViewController(activityItems: [shareText], applicationActivities: nil) + hostingController.present(vc, animated: true) + } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index cdedb32e8..0e7d71594 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -19,19 +19,25 @@ import UIKit enum HomeScreenViewModelAction { case selectRoom(roomIdentifier: String) - case tapUserAvatar + case userMenu(action: HomeScreenViewUserMenuAction) case verifySession } +enum HomeScreenViewUserMenuAction { + case settings + case inviteFriends + case feedback + case signOut +} + enum HomeScreenViewAction { case loadRoomData(roomIdentifier: String) case selectRoom(roomIdentifier: String) - case tapUserAvatar + case userMenu(action: HomeScreenViewUserMenuAction) case verifySession } struct HomeScreenViewState: BindableState { - var userDisplayName: String var userAvatar: UIImage? var showSessionVerificationBanner = false diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index e6f532bec..e55ef63d8 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -39,11 +39,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol // MARK: - Setup - init(initialDisplayName: String, attributedStringBuilder: AttributedStringBuilderProtocol) { + init(attributedStringBuilder: AttributedStringBuilderProtocol) { self.attributedStringBuilder = attributedStringBuilder - super.init(initialViewState: HomeScreenViewState(userDisplayName: initialDisplayName, - isLoadingRooms: true)) + super.init(initialViewState: HomeScreenViewState(isLoadingRooms: true)) } // MARK: - Public @@ -54,8 +53,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol loadRoomDataForIdentifier(roomIdentifier) case .selectRoom(let roomIdentifier): callback?(.selectRoom(roomIdentifier: roomIdentifier)) - case .tapUserAvatar: - callback?(.tapUserAvatar) + case .userMenu(let action): + callback?(.userMenu(action: action)) case .verifySession: callback?(.verifySession) } @@ -113,11 +112,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol func updateWithUserAvatar(_ avatar: UIImage) { state.userAvatar = avatar } - - func updateWithUserDisplayName(_ displayName: String) { - state.userDisplayName = displayName - } - + func showSessionVerificationBanner() { state.showSessionVerificationBanner = true } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift index 3d8a60af6..8a155838b 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift @@ -24,7 +24,6 @@ protocol HomeScreenViewModelProtocol { var context: HomeScreenViewModelType.Context { get } func updateWithUserAvatar(_ avatar: UIImage) - func updateWithUserDisplayName(_ displayName: String) func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) func showSessionVerificationBanner() diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index 5ed002569..11a88c751 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -69,39 +69,71 @@ struct HomeScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button { context.send(viewAction: .tapUserAvatar) } label: { - HStack { - userAvatarImage - .animation(.elementDefault, value: context.viewState.userAvatar) - .transition(.opacity) - - userDisplayNameView - .animation(.elementDefault, value: context.viewState.userDisplayName) - .transition(.opacity) - } - } + userMenuButton } } } - + @ViewBuilder - private var userAvatarImage: some View { - if let avatar = context.viewState.userAvatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .frame(width: 32, height: 32, alignment: .center) - .clipShape(Circle()) - .accessibilityIdentifier("userAvatarImage") + private var userMenuButton: some View { + Menu { + Section { + Button(action: settings) { + Label(ElementL10n.settingsUserSettings, systemImage: "gearshape") + } + } + Section { + Button(action: inviteFriends) { + Label(ElementL10n.inviteFriends, systemImage: "square.and.arrow.up") + } + Button(action: feedback) { + Label(ElementL10n.feedback, systemImage: "questionmark.circle") + } + } + Section { + Button(role: .destructive, action: signOut) { + Label(ElementL10n.actionSignOut, systemImage: "rectangle.portrait.and.arrow.right") + } + } + } label: { + userAvatarImageView + .animation(.elementDefault, value: context.viewState.userAvatar) + .transition(.opacity) } } - - private var userDisplayNameView: some View { - Text(context.viewState.userDisplayName) - .font(.headline) - .fontWeight(.bold) - .foregroundColor(.primary) - .accessibilityIdentifier("userDisplayNameView") + + @ViewBuilder + private var userAvatarImageView: some View { + userAvatarImage + .resizable() + .scaledToFill() + .frame(width: 32, height: 32, alignment: .center) + .clipShape(Circle()) + .accessibilityIdentifier("userAvatarImage") + } + + private var userAvatarImage: Image { + if let avatar = context.viewState.userAvatar { + return Image(uiImage: avatar) + } else { + return .empty + } + } + + private func settings() { + context.send(viewAction: .userMenu(action: .settings)) + } + + private func inviteFriends() { + context.send(viewAction: .userMenu(action: .inviteFriends)) + } + + private func feedback() { + context.send(viewAction: .userMenu(action: .feedback)) + } + + private func signOut() { + context.send(viewAction: .userMenu(action: .signOut)) } } @@ -172,8 +204,7 @@ struct HomeScreen_Previews: PreviewProvider { } static var body: some View { - let viewModel = HomeScreenViewModel(initialDisplayName: "@username:server.com", - attributedStringBuilder: AttributedStringBuilder()) + let viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder()) let eventBrief = EventBrief(eventId: "id", senderId: "senderId", @@ -187,7 +218,6 @@ struct HomeScreen_Previews: PreviewProvider { MockRoomSummary(displayName: "Omega", lastMessage: eventBrief)] viewModel.updateWithRoomSummaries(roomSummaries) - viewModel.updateWithUserDisplayName("username") if let avatarImage = UIImage(systemName: "person.fill") { viewModel.updateWithUserAvatar(avatarImage) diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 7a0146b6f..6fcef0797 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -23,8 +23,7 @@ class HomeScreenViewModelTests: XCTestCase { var context: HomeScreenViewModelType.Context! @MainActor override func setUpWithError() throws { - viewModel = HomeScreenViewModel(initialDisplayName: "@test:example.com", - attributedStringBuilder: AttributedStringBuilder()) + viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder()) context = viewModel.context } @@ -52,14 +51,14 @@ class HomeScreenViewModelTests: XCTestCase { var correctResult = false viewModel.callback = { result in switch result { - case .tapUserAvatar: - correctResult = true + case .userMenu(let action): + correctResult = action == .settings default: break } } - context.send(viewAction: .tapUserAvatar) + context.send(viewAction: .userMenu(action: .settings)) await Task.yield() XCTAssert(correctResult) } diff --git a/changelog.d/179.feature b/changelog.d/179.feature new file mode 100644 index 000000000..eee110ae5 --- /dev/null +++ b/changelog.d/179.feature @@ -0,0 +1 @@ +HomeScreen: Add user options menu to avatar and display name.