diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index a168812bd4..02048f8ae2 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2448,6 +2448,15 @@ To enable access, tap Settings> Location and select Always"; "user_other_session_verified_additional_info" = "This session is ready for secure messaging."; "user_session_push_notifications" = "Push notifications"; "user_session_push_notifications_message" = "When turned on, this session will receive push notifications."; +"user_session_got_it" = "Got it"; +"user_session_verified_session_title" = "Verified sessions"; +"user_session_verified_session_description" = "Verified sessions are anywhere you are using Element after entering your passphrase or confirming your identity with another verified session.\n\nThis means that you have all the keys needed to unlock your encrypted messages and confirm to other users that you trust this session."; +"user_session_unverified_session_title" = "Unverified session"; +"user_session_unverified_session_description" = "Unverified sessions are sessions that have logged in with your credentials but not been cross-verified.\n\nYou should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account."; +"user_session_inactive_session_title" = "Inactive sessions"; +"user_session_inactive_session_description" = "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.\n\nRemoving inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious."; +"user_session_rename_session_title" = "Renaming sessions"; +"user_session_rename_session_description" = "Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here."; "user_other_session_security_recommendation_title" = "Security recommendation"; "user_other_session_unverified_sessions_header_subtitle" = "Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore."; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 1ff68cf26f..55dd3d21bb 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8787,6 +8787,18 @@ public class VectorL10n: NSObject { public static var userSessionDetailsTitle: String { return VectorL10n.tr("Vector", "user_session_details_title") } + /// Got it + public static var userSessionGotIt: String { + return VectorL10n.tr("Vector", "user_session_got_it") + } + /// Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.\n\nRemoving inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious. + public static var userSessionInactiveSessionDescription: String { + return VectorL10n.tr("Vector", "user_session_inactive_session_description") + } + /// Inactive sessions + public static var userSessionInactiveSessionTitle: String { + return VectorL10n.tr("Vector", "user_session_inactive_session_title") + } /// %1$@ · %2$@ public static func userSessionItemDetails(_ p1: String, _ p2: String) -> String { return VectorL10n.tr("Vector", "user_session_item_details", p1, p2) @@ -8823,6 +8835,14 @@ public class VectorL10n: NSObject { public static var userSessionPushNotificationsMessage: String { return VectorL10n.tr("Vector", "user_session_push_notifications_message") } + /// Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here. + public static var userSessionRenameSessionDescription: String { + return VectorL10n.tr("Vector", "user_session_rename_session_description") + } + /// Renaming sessions + public static var userSessionRenameSessionTitle: String { + return VectorL10n.tr("Vector", "user_session_rename_session_title") + } /// Unverified session public static var userSessionUnverified: String { return VectorL10n.tr("Vector", "user_session_unverified") @@ -8831,6 +8851,14 @@ public class VectorL10n: NSObject { public static var userSessionUnverifiedAdditionalInfo: String { return VectorL10n.tr("Vector", "user_session_unverified_additional_info") } + /// Unverified sessions are sessions that have logged in with your credentials but not been cross-verified.\n\nYou should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account. + public static var userSessionUnverifiedSessionDescription: String { + return VectorL10n.tr("Vector", "user_session_unverified_session_description") + } + /// Unverified session + public static var userSessionUnverifiedSessionTitle: String { + return VectorL10n.tr("Vector", "user_session_unverified_session_title") + } /// Unverified public static var userSessionUnverifiedShort: String { return VectorL10n.tr("Vector", "user_session_unverified_short") @@ -8855,6 +8883,14 @@ public class VectorL10n: NSObject { public static var userSessionVerifiedAdditionalInfo: String { return VectorL10n.tr("Vector", "user_session_verified_additional_info") } + /// Verified sessions are anywhere you are using Element after entering your passphrase or confirming your identity with another verified session.\n\nThis means that you have all the keys needed to unlock your encrypted messages and confirm to other users that you trust this session. + public static var userSessionVerifiedSessionDescription: String { + return VectorL10n.tr("Vector", "user_session_verified_session_description") + } + /// Verified sessions + public static var userSessionVerifiedSessionTitle: String { + return VectorL10n.tr("Vector", "user_session_verified_session_title") + } /// Verified public static var userSessionVerifiedShort: String { return VectorL10n.tr("Vector", "user_session_verified_short") diff --git a/RiotSwiftUI/Modules/Common/Extensions/View.swift b/RiotSwiftUI/Modules/Common/Extensions/View.swift new file mode 100644 index 0000000000..2ab99f8844 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Extensions/View.swift @@ -0,0 +1,24 @@ +// +// 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 UIKit +import SwiftUI + +extension View { + func hideKeyboard() { + UIApplication.shared.vc_closeKeyboard() + } +} diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift b/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift new file mode 100644 index 0000000000..6fb7fc1103 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/InfoSheet/Coordinator/InfoSheetCoordinator.swift @@ -0,0 +1,91 @@ +// +// Copyright 2021 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 CommonKit +import SwiftUI + +struct InfoSheetCoordinatorParameters { + let title: String + let description: String + let action: InfoSheet.Action + let parentSize: CGSize? +} + +final class InfoSheetCoordinator: Coordinator, Presentable { + private let parameters: InfoSheetCoordinatorParameters + private let infoSheetHostingController: UIViewController + private var infoSheetViewModel: InfoSheetViewModelProtocol + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((InfoSheetViewModelResult) -> Void)? + + init(parameters: InfoSheetCoordinatorParameters) { + self.parameters = parameters + + let viewModel = InfoSheetViewModel(title: parameters.title, description: parameters.description, action: parameters.action) + let view = InfoSheet(viewModel: viewModel.context) + infoSheetViewModel = viewModel + let controller = VectorHostingController(rootView: view) + infoSheetHostingController = controller + setupPresentation(of: controller) + } + + // MARK: - Public + + func start() { + MXLog.debug("[InfoSheetCoordinator] did start.") + infoSheetViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[InfoSheetCoordinator] InfoSheetViewModel did complete with result: \(result).") + self.completion?(result) + } + } + + func toPresentable() -> UIViewController { + infoSheetHostingController + } +} + +private extension InfoSheetCoordinator { + // The bottom sheet should be presented with the content intrinsic height as for design requirement + // We can do it easily just on iOS 16+ + func setupPresentation(of viewController: VectorHostingController) { + let cornerRadius: CGFloat = 24 + + guard + #available(iOS 16, *), + let parentSize = parameters.parentSize, + let presentationController = viewController.sheetPresentationController + else { + viewController.bottomSheetPreferences = .init(cornerRadius: cornerRadius) + return + } + + let intrisincSize = viewController.view.systemLayoutSizeFitting(.init(width: parentSize.width, height: 0), + withHorizontalFittingPriority: .defaultHigh, + verticalFittingPriority: .defaultLow) + + presentationController.preferredCornerRadius = cornerRadius + presentationController.prefersGrabberVisible = true + presentationController.detents = [ + .custom { context in + min(context.maximumDetentValue, intrisincSize.height) + }, + .large() + ] + } +} diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetModels.swift b/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetModels.swift new file mode 100644 index 0000000000..0652bfe83e --- /dev/null +++ b/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetModels.swift @@ -0,0 +1,33 @@ +// +// Copyright 2021 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. +// + +// MARK: View model + +enum InfoSheetViewModelResult { + case actionTriggered +} + +// MARK: View + +struct InfoSheetViewState: BindableState { + let title: String + let description: String + let action: InfoSheet.Action +} + +enum InfoSheetViewAction { + case actionTriggered +} diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetViewModel.swift b/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetViewModel.swift new file mode 100644 index 0000000000..c586864951 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetViewModel.swift @@ -0,0 +1,36 @@ +// +// Copyright 2021 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 + +typealias InfoSheetViewModelType = StateStoreViewModel + +class InfoSheetViewModel: InfoSheetViewModelType, InfoSheetViewModelProtocol { + var completion: ((InfoSheetViewModelResult) -> Void)? + + init(title: String, description: String, action: InfoSheet.Action) { + super.init(initialViewState: InfoSheetViewState(title: title, description: description, action: action)) + } + + // MARK: - Public + + override func process(viewAction: InfoSheetViewAction) { + switch viewAction { + case .actionTriggered: + completion?(.actionTriggered) + } + } +} diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetViewModelProtocol.swift b/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetViewModelProtocol.swift new file mode 100644 index 0000000000..97bff28470 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/InfoSheet/InfoSheetViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol InfoSheetViewModelProtocol { + var completion: ((InfoSheetViewModelResult) -> Void)? { get set } + var context: InfoSheetViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/MockInfoSheetScreenState.swift b/RiotSwiftUI/Modules/Common/InfoSheet/MockInfoSheetScreenState.swift new file mode 100644 index 0000000000..62d86a681a --- /dev/null +++ b/RiotSwiftUI/Modules/Common/InfoSheet/MockInfoSheetScreenState.swift @@ -0,0 +1,57 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockInfoSheetScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case sheet(title: String, subtitle: String, action: InfoSheet.Action) + + /// The associated screen + var screenType: Any.Type { + InfoSheet.self + } + + /// A list of screen state definitions + static var allCases: [MockInfoSheetScreenState] { + // Each of the presence statuses + [.sheet(title: VectorL10n.userSessionVerifiedSessionTitle, subtitle: VectorL10n.userSessionVerifiedSessionDescription, action: .init(text: VectorL10n.userSessionGotIt, action: { }))] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let model: (title: String, subtitle: String, action: InfoSheet.Action) + + switch self { + case let .sheet(title, subtitle, action): + model = (title, subtitle, action) + } + let viewModel = InfoSheetViewModel(title: model.title, description: model.subtitle, action: model.action) + + // can simulate service and viewModel actions here if needs be. + + return ( + [model, viewModel], + AnyView(InfoSheet(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Common/InfoSheet/View/InfoSheet.swift b/RiotSwiftUI/Modules/Common/InfoSheet/View/InfoSheet.swift new file mode 100644 index 0000000000..86f6cdd5dd --- /dev/null +++ b/RiotSwiftUI/Modules/Common/InfoSheet/View/InfoSheet.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 SwiftUI + +struct InfoSheet: View { + struct Action { + let text: String + let action: () -> Void + } + + @Environment(\.theme) var theme: ThemeSwiftUI + private let viewModel: InfoSheetViewModel.Context + + init(viewModel: InfoSheetViewModel.Context) { + self.viewModel = viewModel + } + + var body: some View { + let padding: CGFloat = 16 + VStack(spacing: 24) { + VStack(spacing: 18) { + Text(viewModel.viewState.title) + .font(theme.fonts.headline) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier(viewModel.viewState.title) + .padding([.leading, .trailing], padding) + + Rectangle() + .foregroundColor(theme.colors.system) + .frame(height: 1) + + Text(viewModel.viewState.description) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier(viewModel.viewState.description) + .padding([.leading, .trailing], padding) + .fixedSize(horizontal: false, vertical: true) + } + .layoutPriority(1) + + Button { + viewModel.viewState.action.action() + viewModel.send(viewAction: .actionTriggered) + } + label: { + Text(viewModel.viewState.action.text) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.background) + .frame(height: 46) + .frame(maxWidth: .infinity) + .accessibilityIdentifier(viewModel.viewState.action.text) + } + .background(theme.colors.accent) + .cornerRadius(8) + .padding([.leading, .trailing], padding) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .padding(.bottom, padding) + .padding(.top, 32) + .frame(maxWidth: .infinity) + .background(theme.colors.background.ignoresSafeArea(edges: .bottom)) + } +} + +// MARK: - Previews + +struct InfoSheet_Previews: PreviewProvider { + static let stateRenderer = MockInfoSheetScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift b/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift index e5673b12c4..107b3ee1b2 100644 --- a/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift +++ b/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift @@ -39,10 +39,11 @@ struct InlineTextButton: View { /// - mainText: The main text that shouldn't appear tappable. This must contain a single `%@` placeholder somewhere within. /// - tappableText: The tappable text that will be substituted into the `%@` placeholder. /// - action: The action to perform when tapping the button. - internal init(_ mainText: String, tappableText: String, action: @escaping () -> Void) { + /// - alwaysCallAction: If true calls the action on tap action even if the `tappableText` isn't found inside the `mainText` + init(_ mainText: String, tappableText: String, alwaysCallAction: Bool = true, action: @escaping () -> Void) { guard let range = mainText.range(of: "%@") else { components = [StringComponent(string: Substring(mainText), isTinted: false)] - self.action = action + self.action = alwaysCallAction ? action : { } return } @@ -69,8 +70,13 @@ struct InlineTextButton: View { func makeBody(configuration: Configuration) -> some View { components.reduce(Text("")) { lastValue, component in - lastValue + Text(component.string) - .foregroundColor(component.isTinted ? .accentColor.opacity(configuration.isPressed ? 0.2 : 1) : nil) + var text: Text = .init(component.string) + + if component.isTinted { + text = text.foregroundColor(.accentColor.opacity(configuration.isPressed ? 0.2 : 1)) + } + + return lastValue + text } } } diff --git a/RiotSwiftUI/Modules/Common/Util/SearchBar.swift b/RiotSwiftUI/Modules/Common/Util/SearchBar.swift index 63b3863b99..fb05eff952 100644 --- a/RiotSwiftUI/Modules/Common/Util/SearchBar.swift +++ b/RiotSwiftUI/Modules/Common/Util/SearchBar.swift @@ -64,7 +64,7 @@ struct SearchBar: View { Button(action: { self.isEditing = false self.text = "" - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + self.hideKeyboard() }) { Text(VectorL10n.cancel) .font(theme.fonts.body) diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index eb6516645a..9f6f6460f2 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -50,19 +50,12 @@ struct UserSessionCardView: View { .foregroundColor(theme.colors[keyPath: viewData.verificationStatusColor]) .multilineTextAlignment(.center) - if viewData.isCurrentSessionDisplayMode { - Text(viewData.verificationStatusAdditionalInfoText) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.secondaryContent) - .multilineTextAlignment(.center) - } else { - InlineTextButton(viewData.verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) { - onLearnMoreAction?() - } - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.secondaryContent) - .multilineTextAlignment(.center) + InlineTextButton(viewData.verificationStatusAdditionalInfoText, tappableText: VectorL10n.userSessionLearnMore, alwaysCallAction: false) { + onLearnMoreAction?() } + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .multilineTextAlignment(.center) if showExtraInformations { VStack(spacing: 2) { diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift index 1ab0c8f4a7..60adcb4c8a 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift @@ -39,7 +39,7 @@ struct UserSessionCardViewData { let deviceAvatarViewData: DeviceAvatarViewData - /// Indicate if the current user session is shown and to adpat the layout + /// Indicate if the current user session is shown and to adapt the layout let isCurrentSessionDisplayMode: Bool /// The name of the shield image to show the verification status. @@ -82,9 +82,9 @@ struct UserSessionCardViewData { var verificationStatusAdditionalInfoText: String { switch verificationState { case .verified: - return isCurrentSessionDisplayMode ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userOtherSessionVerifiedAdditionalInfo + return isCurrentSessionDisplayMode ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userOtherSessionVerifiedAdditionalInfo + " %@" case .unverified: - return isCurrentSessionDisplayMode ? VectorL10n.userSessionUnverifiedAdditionalInfo : VectorL10n.userOtherSessionUnverifiedAdditionalInfo + return isCurrentSessionDisplayMode ? VectorL10n.userSessionUnverifiedAdditionalInfo : VectorL10n.userOtherSessionUnverifiedAdditionalInfo + " %@" case .unknown: return VectorL10n.userSessionVerificationUnknownAdditionalInfo } diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 5506f8d079..78df04c0b5 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -22,7 +22,7 @@ struct UserSessionsFlowCoordinatorParameters { let router: NavigationRouterType } -final class UserSessionsFlowCoordinator: Coordinator, Presentable { +final class UserSessionsFlowCoordinator: NSObject, Coordinator, Presentable { private let parameters: UserSessionsFlowCoordinatorParameters private let allSessionsService: UserSessionsOverviewService @@ -125,6 +125,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } else { self.showLogoutConfirmation(for: [sessionInfo]) } + case let .showSessionStateInfo(sessionInfo): + self.showInfoSheet(parameters: .init(userSessionInfo: sessionInfo, parentSize: self.toPresentable().view.bounds.size)) } } pushScreen(with: coordinator) @@ -164,6 +166,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { self.openSessionOverview(sessionInfo: session) case let .logoutFromUserSessions(sessionInfos: sessionInfos): self.showLogoutConfirmation(for: sessionInfos) + case let .showSessionStateByFilter(filter): + self.showInfoSheet(parameters: .init(filter: filter, parentSize: self.toPresentable().view.bounds.size)) } } pushScreen(with: coordinator) @@ -191,6 +195,25 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { navigationRouter.present(alert, animated: true) } + private func showInfoSheet(parameters: InfoSheetCoordinatorParameters) { + let coordinator = InfoSheetCoordinator(parameters: parameters) + + coordinator.toPresentable().presentationController?.delegate = self + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + + switch result { + case .actionTriggered: + self.navigationRouter.dismissModule(animated: true, completion: nil) + self.remove(childCoordinator: coordinator) + } + } + + add(childCoordinator: coordinator) + coordinator.start() + navigationRouter.present(coordinator, animated: true) + } + private func showLogoutConfirmationForCurrentSession() { let flowPresenter = SignOutFlowPresenter(session: parameters.session, presentingViewController: toPresentable()) flowPresenter.delegate = self @@ -275,7 +298,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { let modalRouter = NavigationRouter(navigationController: RiotNavigationController()) modalRouter.setRootModule(coordinator) coordinator.start() - + modalRouter.toPresentable().presentationController?.delegate = self navigationRouter.present(modalRouter, animated: true) } @@ -408,3 +431,85 @@ extension UserSessionsFlowCoordinator: UserVerificationCoordinatorDelegate { remove(childCoordinator: coordinator) } } + +// MARK: UIAdaptivePresentationControllerDelegate + +extension UserSessionsFlowCoordinator: UIAdaptivePresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard let coordinator = childCoordinators.last else { + return + } + + remove(childCoordinator: coordinator) + } +} + +// MARK: Private + +private extension InfoSheetCoordinatorParameters { + init(userSessionInfo: UserSessionInfo, parentSize: CGSize) { + self.init(title: userSessionInfo.bottomSheetTitle, + description: userSessionInfo.bottomSheetDescription, + action: .init(text: VectorL10n.userSessionGotIt, action: { }), + parentSize: parentSize) + } + + init(filter: UserOtherSessionsFilter, parentSize: CGSize) { + self.init(title: filter.bottomSheetTitle, + description: filter.bottomSheetDescription, + action: .init(text: VectorL10n.userSessionGotIt, action: { }), + parentSize: parentSize) + } +} + +private extension UserSessionInfo { + var bottomSheetTitle: String { + switch verificationState { + case .unverified: + return VectorL10n.userSessionUnverifiedSessionTitle + case .verified: + return VectorL10n.userSessionVerifiedSessionTitle + case .unknown: + return "" + } + } + + var bottomSheetDescription: String { + switch verificationState { + case .unverified: + return VectorL10n.userSessionUnverifiedSessionDescription + case .verified: + return VectorL10n.userSessionVerifiedSessionDescription + case .unknown: + return "" + } + } +} + +private extension UserOtherSessionsFilter { + var bottomSheetTitle: String { + switch self { + case .unverified: + return VectorL10n.userSessionUnverifiedSessionTitle + case .verified: + return VectorL10n.userSessionVerifiedSessionTitle + case .inactive: + return VectorL10n.userSessionInactiveSessionTitle + case .all: + return "" + } + } + + var bottomSheetDescription: String { + switch self { + case .unverified: + return VectorL10n.userSessionUnverifiedSessionDescription + case .verified: + return VectorL10n.userSessionVerifiedSessionDescription + case .inactive: + return VectorL10n.userSessionInactiveSessionDescription + case .all: + return "" + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift index 98dbb144ea..3ea87bed1f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -59,6 +59,8 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable { self.completion?(.openSessionOverview(sessionInfo: session)) case let .logoutFromUserSessions(sessionInfos: sessionInfos): self.completion?(.logoutFromUserSessions(sessionInfos: sessionInfos)) + case .showSessionStateInfo(filter: let filter): + self.completion?(.showSessionStateByFilter(filter: filter)) } MXLog.debug("[UserOtherSessionsCoordinator] UserOtherSessionsViewModel did complete with result: \(result).") } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift index 6987c15e03..9a5702be34 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -20,9 +20,9 @@ import XCTest class UserOtherSessionsUITests: MockScreenTestCase { func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) - XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionFilterMenuInactive].exists) - XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) + let buttonLearnMore = app.buttons["\(VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo) \(VectorL10n.userSessionLearnMore)"] + XCTAssertTrue(buttonLearnMore.exists) } func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() { @@ -33,22 +33,24 @@ class UserOtherSessionsUITests: MockScreenTestCase { func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctHeaderDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title) - XCTAssertTrue(app.staticTexts[VectorL10n.userSessionUnverifiedShort].exists) - XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionFilterMenuUnverified].exists) + let buttonLearnMore = app.buttons["\(VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle) \(VectorL10n.userSessionLearnMore)"] + XCTAssertTrue(buttonLearnMore.exists) } func test_whenOtherSessionsWithAllSessionFilterPresented_correctHeaderDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) - XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo].exists) + XCTAssertTrue(app.buttons[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo].exists) } func test_whenOtherSessionsWithVerifiedSessionFilterPresented_correctHeaderDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.verifiedSessions.title) - XCTAssertTrue(app.staticTexts[VectorL10n.userSessionVerifiedShort].exists) - XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionFilterMenuVerified].exists) + let buttonLearnMore = app.buttons["\(VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle) \(VectorL10n.userSessionLearnMore)"] + XCTAssertTrue(buttonLearnMore.exists) } func test_whenOtherSessionsMoreMenuButtonSelected_moreMenuIsCorrect() { @@ -104,4 +106,12 @@ class UserOtherSessionsUITests: MockScreenTestCase { sessionListItem.tap() XCTAssertFalse(signOutButton.isEnabled) } + + func test_whenAllOtherSessionsAreShown_learnMoreButtonIsNotShown() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title) + let button = app.buttons[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo] + let buttonLearnMore = app.buttons["\(VectorL10n.userSessionsOverviewOtherSessionsSectionInfo) + \(VectorL10n.userSessionLearnMore)"] + XCTAssertTrue(button.exists) + XCTAssertFalse(buttonLearnMore.exists) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift index 0a140203e9..0d5aa1c9f8 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -20,11 +20,11 @@ import XCTest class UserOtherSessionsViewModelTests: XCTestCase { private let unverifiedSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionUnverifiedShort, - subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle, + subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle + " %@", iconName: Asset.Images.userOtherSessionsUnverified.name) private let inactiveSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuInactive, - subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo + " %@", iconName: Asset.Images.userOtherSessionsInactive.name) private let allSectionHeader = UserOtherSessionsHeaderViewData(title: nil, @@ -32,7 +32,7 @@ class UserOtherSessionsViewModelTests: XCTestCase { iconName: nil) private let verifiedSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuVerified, - subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle, + subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle + " %@", iconName: Asset.Images.userOtherSessionsVerified.name) func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift index b3b4efa008..deeb5ab955 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift @@ -21,6 +21,7 @@ import Foundation enum UserOtherSessionsCoordinatorResult { case openSessionOverview(sessionInfo: UserSessionInfo) case logoutFromUserSessions(sessionInfos: [UserSessionInfo]) + case showSessionStateByFilter(filter: UserOtherSessionsFilter) } // MARK: View model @@ -28,6 +29,7 @@ enum UserOtherSessionsCoordinatorResult { enum UserOtherSessionsViewModelResult: Equatable { case showUserSessionOverview(sessionInfo: UserSessionInfo) case logoutFromUserSessions(sessionInfos: [UserSessionInfo]) + case showSessionStateInfo(filter: UserOtherSessionsFilter) } // MARK: View @@ -57,4 +59,5 @@ enum UserOtherSessionsViewAction { case logoutAllUserSessions case logoutSelectedUserSessions case showLocationInfo + case viewSessionInfo } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index 980a980f43..741593fe3a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -77,6 +77,8 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi case .showLocationInfo: settingsService.showIPAddressesInSessionsManager.toggle() state.showLocationInfo = settingsService.showIPAddressesInSessionsManager + case .viewSessionInfo: + completion?(.showSessionStateInfo(filter: state.bindings.filter)) } } @@ -137,15 +139,15 @@ private extension UserOtherSessionsFilter { iconName: nil) case .inactive: return UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuInactive, - subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo + " %@", iconName: Asset.Images.userOtherSessionsInactive.name) case .unverified: return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionUnverifiedShort, - subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle, + subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle + " %@", iconName: Asset.Images.userOtherSessionsUnverified.name) case .verified: return UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuVerified, - subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle, + subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle + " %@", iconName: Asset.Images.userOtherSessionsVerified.name) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift index c4a4cd9d2b..267008b889 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -31,9 +31,13 @@ struct UserOtherSessions: View { itemsView() } } header: { - UserOtherSessionsHeaderView(viewData: viewModel.viewState.header) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 24.0) + UserOtherSessionsHeaderView( + viewData: viewModel.viewState.header, + onLearnMoreAction: { + viewModel.send(viewAction: .viewSessionInfo) + } + ) + .padding(.top) } } if viewModel.isEditModeEnabled { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift index 9cdfb69959..a815d7875f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -17,9 +17,9 @@ import SwiftUI struct UserOtherSessionsHeaderViewData: Hashable { - var title: String? + let title: String? let subtitle: String - var iconName: String? + let iconName: String? } struct UserOtherSessionsHeaderView: View { @@ -30,6 +30,7 @@ struct UserOtherSessionsHeaderView: View { @Environment(\.theme) private var theme let viewData: UserOtherSessionsHeaderViewData + var onLearnMoreAction: (() -> Void)? var body: some View { HStack(alignment: .top, spacing: 0) { @@ -48,10 +49,12 @@ struct UserOtherSessionsHeaderView: View { .foregroundColor(theme.colors.primaryContent) .padding(.vertical, 9.0) } - Text(viewData.subtitle) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.secondaryContent) - .padding(.bottom, 20.0) + InlineTextButton(viewData.subtitle, tappableText: VectorL10n.userSessionLearnMore, alwaysCallAction: false) { + onLearnMoreAction?() + } + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 20.0) }) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift index 8d8890b2c0..201f81c12e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Coordinator/UserSessionNameCoordinator.swift @@ -22,7 +22,7 @@ struct UserSessionNameCoordinatorParameters { let sessionInfo: UserSessionInfo } -final class UserSessionNameCoordinator: Coordinator, Presentable { +final class UserSessionNameCoordinator: NSObject, Coordinator, Presentable { private let parameters: UserSessionNameCoordinatorParameters private let userSessionNameHostingController: UIViewController private var userSessionNameViewModel: UserSessionNameViewModelProtocol @@ -58,6 +58,11 @@ final class UserSessionNameCoordinator: Coordinator, Presentable { self.updateName(newName) case .cancel: self.completion?(.cancel) + case .learnMore: + self.showInfoSheet(parameters: .init(title: VectorL10n.userSessionRenameSessionTitle, + description: VectorL10n.userSessionRenameSessionDescription, + action: .init(text: VectorL10n.userSessionGotIt, action: {}), + parentSize: self.toPresentable().view.bounds.size)) } } } @@ -95,4 +100,34 @@ final class UserSessionNameCoordinator: Coordinator, Presentable { private func stopLoading() { loadingIndicator = nil } + + private func showInfoSheet(parameters: InfoSheetCoordinatorParameters) { + let coordinator = InfoSheetCoordinator(parameters: parameters) + coordinator.toPresentable().presentationController?.delegate = self + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + + switch result { + case .actionTriggered: + self.toPresentable().dismiss(animated: true) + self.remove(childCoordinator: coordinator) + } + } + + add(childCoordinator: coordinator) + coordinator.start() + toPresentable().present(coordinator.toPresentable(), animated: true) + } +} + +// MARK: UIAdaptivePresentationControllerDelegate + +extension UserSessionNameCoordinator: UIAdaptivePresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard let coordinator = childCoordinators.last else { + return + } + + remove(childCoordinator: coordinator) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift index ebe909e840..85e27900f0 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameModels.swift @@ -32,6 +32,8 @@ enum UserSessionNameViewModelResult { case cancel /// Update the session name to the supplied string. case updateName(String) + /// The user tapped the learn more button. + case learnMore } // MARK: View @@ -59,6 +61,6 @@ enum UserSessionNameViewAction { case done /// The user tapped the cancel button. case cancel - /// The user tapped the Learn More link. + /// The user tapped the learn more button. case learnMore } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift index ad2b8d7cdf..a962119b2f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/UserSessionNameViewModel.swift @@ -35,7 +35,7 @@ class UserSessionNameViewModel: UserSessionNameViewModelType, UserSessionNameVie case .cancel: completion?(.cancel) case .learnMore: - #warning("To be implemented as part of PSG-714.") + completion?(.learnMore) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift index 8f0f55b02d..536f96b5e5 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Coordinator/UserSessionOverviewCoordinator.swift @@ -73,6 +73,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable { self.completion?(.renameSession(sessionInfo)) case let .logoutOfSession(sessionInfo): self.completion?(.logoutOfSession(sessionInfo)) + case let .showSessionStateInfo(sessionInfo): + self.completion?(.showSessionStateInfo(sessionInfo)) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift index 9d4d21560d..6b7040a2c9 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift @@ -23,8 +23,8 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { // A case for each state you want to represent // with specific, minimal associated data that will allow you // mock that screen. - case currentSession - case otherSession + case currentSession(sessionState: UserSessionInfo.VerificationState) + case otherSession(sessionState: UserSessionInfo.VerificationState) case sessionWithPushNotifications(enabled: Bool) case remotelyTogglingPushersNotAvailable @@ -35,8 +35,10 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { /// A list of screen state definitions static var allCases: [MockUserSessionOverviewScreenState] { - [.currentSession, - .otherSession, + [.currentSession(sessionState: .unverified), + .currentSession(sessionState: .verified), + .otherSession(sessionState: .verified), + .otherSession(sessionState: .unverified), .sessionWithPushNotifications(enabled: true), .sessionWithPushNotifications(enabled: false), .remotelyTogglingPushersNotAvailable] @@ -47,11 +49,11 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { let session: UserSessionInfo let service: UserSessionOverviewServiceProtocol switch self { - case .currentSession: + case .currentSession(let state): session = UserSessionInfo(id: "alice", name: "iOS", deviceType: .mobile, - verificationState: .unverified, + verificationState: state, lastSeenIP: "10.0.0.10", lastSeenTimestamp: nil, applicationName: "Element iOS", @@ -65,11 +67,11 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { isActive: true, isCurrent: true) service = MockUserSessionOverviewService() - case .otherSession: + case .otherSession(let state): session = UserSessionInfo(id: "1", name: "macOS", deviceType: .desktop, - verificationState: .verified, + verificationState: state, lastSeenIP: "1.0.0.1", lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, applicationName: "Element MacOS", @@ -126,3 +128,18 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { return ([viewModel], AnyView(UserSessionOverview(viewModel: viewModel.context))) } } + +extension MockUserSessionOverviewScreenState: CustomStringConvertible { + var description: String { + switch self { + case .currentSession(let sessionState): + return "currentSession\(sessionState)" + case .otherSession(let sessionState): + return "otherSession\(sessionState)" + case .remotelyTogglingPushersNotAvailable: + return "remotelyTogglingPushersNotAvailable" + case .sessionWithPushNotifications(let enabled): + return "sessionWithPushNotifications\(enabled)" + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift index 862d07453d..af71412477 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/UI/UserSessionOverviewUITests.swift @@ -19,19 +19,19 @@ import XCTest class UserSessionOverviewUITests: MockScreenTestCase { func test_whenCurrentSessionSelected_correctNavTittleDisplayed() { - app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.currentSession.title) + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.currentSession(sessionState: .unverified).title) let navTitle = VectorL10n.userSessionOverviewCurrentSessionTitle XCTAssertTrue(app.navigationBars[navTitle].staticTexts[navTitle].exists) } func test_whenOtherSessionSelected_correctNavTittleDisplayed() { - app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession.title) + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession(sessionState: .verified).title) let navTitle = VectorL10n.userSessionOverviewSessionTitle XCTAssertTrue(app.navigationBars[navTitle].staticTexts[navTitle].exists) } func test_whenSessionOverviewPresented_sessionDetailsButtonExists() { - app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.currentSession.title) + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.currentSession(sessionState: .unverified).title) XCTAssertTrue(app.buttons[VectorL10n.userSessionOverviewSessionDetailsButtonTitle].exists) } @@ -63,7 +63,7 @@ class UserSessionOverviewUITests: MockScreenTestCase { } func test_whenSessionSelected_kebabMenuShows() { - app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession.title) + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession(sessionState: .verified).title) let navTitle = VectorL10n.userSessionOverviewSessionTitle let barButton = app.navigationBars[navTitle].buttons["Menu"] XCTAssertTrue(barButton.exists) @@ -71,4 +71,26 @@ class UserSessionOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.buttons[VectorL10n.signOut].exists) XCTAssertTrue(app.buttons[VectorL10n.manageSessionRename].exists) } + + func test_whenOtherSessionSelected_learnMoreButtonDoesnExist() { + let title = MockUserSessionOverviewScreenState.currentSession(sessionState: .verified).title + app.goToScreenWithIdentifier(title) + let buttonId = "\(VectorL10n.userOtherSessionVerifiedAdditionalInfo) \(VectorL10n.userSessionLearnMore)" + let button = app.buttons[buttonId] + XCTAssertFalse(button.exists) + } + + func test_whenOtherVerifiedSessionSelected_learnMoreButtonExists() { + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession(sessionState: .verified).title) + let buttonId = "\(VectorL10n.userOtherSessionVerifiedAdditionalInfo) \(VectorL10n.userSessionLearnMore)" + let button = app.buttons[buttonId] + XCTAssertTrue(button.exists) + } + + func test_whenOtherUnverifiedSessionSelected_learnMoreButtonExists() { + app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.otherSession(sessionState: .unverified).title) + let buttonId = "\(VectorL10n.userOtherSessionUnverifiedAdditionalInfo) \(VectorL10n.userSessionLearnMore)" + let button = app.buttons[buttonId] + XCTAssertTrue(button.exists) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift index 8ca4935a67..6d5ae25b63 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewModels.swift @@ -23,6 +23,7 @@ enum UserSessionOverviewCoordinatorResult { case verifySession(UserSessionInfo) case renameSession(UserSessionInfo) case logoutOfSession(UserSessionInfo) + case showSessionStateInfo(UserSessionInfo) } // MARK: View model @@ -32,6 +33,7 @@ enum UserSessionOverviewViewModelResult: Equatable { case verifySession(UserSessionInfo) case renameSession(UserSessionInfo) case logoutOfSession(UserSessionInfo) + case showSessionStateInfo(UserSessionInfo) } // MARK: View @@ -52,4 +54,5 @@ enum UserSessionOverviewViewAction { case renameSession case logoutOfSession case showLocationInfo + case viewSessionInfo } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift index 846b791ac3..0972d79714 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift @@ -102,6 +102,8 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio case .showLocationInfo: settingService.showIPAddressesInSessionsManager.toggle() state.showLocationInfo = settingService.showIPAddressesInSessionsManager + case .viewSessionInfo: + completion?(.showSessionStateInfo(sessionInfo)) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift index 201445700c..023952845d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift @@ -24,15 +24,20 @@ struct UserSessionOverview: View { var body: some View { ScrollView { UserSessionCardView( - viewData: viewModel.viewState.cardViewData, onVerifyAction: { _ in + viewData: viewModel.viewState.cardViewData, + onVerifyAction: { _ in viewModel.send(viewAction: .verifySession) }, onViewDetailsAction: { _ in viewModel.send(viewAction: .viewSessionDetails) }, + onLearnMoreAction: { + viewModel.send(viewAction: .viewSessionInfo) + }, showLocationInformations: viewModel.viewState.showLocationInfo ) .padding(16) + SwiftUI.Section { UserSessionOverviewItem(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, showsChevron: true) { diff --git a/changelog.d/pr-6992.change b/changelog.d/pr-6992.change new file mode 100644 index 0000000000..d60e487fcd --- /dev/null +++ b/changelog.d/pr-6992.change @@ -0,0 +1 @@ +Add informational sheets for user's session states.