Merge branch 'develop' of github.com:vector-im/element-ios into langleyd/6830_wysiwyg_core_formatting

This commit is contained in:
David Langley
2022-10-12 11:46:24 +01:00
95 changed files with 2911 additions and 524 deletions
@@ -34,6 +34,8 @@ protocol AuthenticationRestClient: AnyObject {
func login(parameters: LoginParameters) async throws -> MXCredentials
func login(parameters: [String: Any]) async throws -> MXCredentials
func generateLoginToken() async throws -> MXLoginToken
// MARK: Registration
var registerFallbackURL: URL { get }
@@ -19,6 +19,7 @@ import Foundation
/// The static list of mocked screens in RiotSwiftUI
enum MockAppScreens {
static let appScreens: [MockScreenState.Type] = [
MockUserSessionNameScreenState.self,
MockUserOtherSessionsScreenState.self,
MockUserSessionsOverviewScreenState.self,
MockUserSessionDetailsScreenState.self,
@@ -0,0 +1,58 @@
//
// 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 Introspect
import SwiftUI
/// Introspects the view to find a table view on iOS 14/15 or a collection view
/// on iOS 16 and sets the background to the specified color.
struct ListBackgroundModifier: ViewModifier {
/// The background color.
let color: Color
func body(content: Content) -> some View {
// When using Xcode 13
#if compiler(<5.7)
// SwiftUI's List is backed by a table view.
content.introspectTableView { $0.backgroundColor = UIColor(color) }
// When using Xcode 14+
#else
if #available(iOS 16, *) {
// SwiftUI's List is backed by a collection view on iOS 16.
content
.introspectCollectionView { $0.backgroundColor = UIColor(color) }
.scrollContentBackground(.hidden)
} else {
// SwiftUI's List is backed by a table view on iOS 15 and below.
content.introspectTableView { $0.backgroundColor = UIColor(color) }
}
#endif
}
}
extension View {
/// Sets the background color of a `List` using introspection.
func listBackgroundColor(_ color: Color) -> some View {
modifier(ListBackgroundModifier(color: color))
}
/// Finds a `UICollectionView` from a `SwiftUI.List`, or `SwiftUI.List` child.
/// Stop gap until https://github.com/siteline/SwiftUI-Introspect/pull/169
func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View {
introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: customize)
}
}
@@ -103,8 +103,10 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
self.completion?()
case .share(let latitude, let longitude, let coordinateType):
self.shareStaticLocation(latitude: latitude, longitude: longitude, coordinateType: coordinateType)
self.completion?()
case .shareLiveLocation(let timeout):
self.startLiveLocationSharing(with: timeout)
self.completion?()
case .checkLiveLocationCanBeStarted(let completion):
self.checkLiveLocationCanBeStarted(completion: completion)
}
@@ -125,43 +127,23 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
}
private func shareStaticLocation(latitude: Double, longitude: Double, coordinateType: LocationSharingCoordinateType) {
locationSharingViewModel.startLoading()
parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil, coordinateType: coordinateType.eventAssetType()) { [weak self] _ in
guard let self = self else { return }
self.locationSharingViewModel.stopLoading()
self.completion?()
} failure: { [weak self] error in
guard let self = self else { return }
parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil, coordinateType: coordinateType.eventAssetType()) { _ in
} failure: { error in
MXLog.error("[LocationSharingCoordinator] Failed sharing location", context: error)
self.locationSharingViewModel.stopLoading(error: .locationSharingError)
}
}
private func startLiveLocationSharing(with timeout: TimeInterval) {
guard let locationService = parameters.roomDataSource.mxSession.locationService, let roomId = parameters.roomDataSource.roomId else {
locationSharingViewModel.stopLoading(error: .locationSharingError)
return
}
locationService.startUserLocationSharing(withRoomId: roomId, description: nil, timeout: timeout) { [weak self] response in
guard let self = self else { return }
locationService.startUserLocationSharing(withRoomId: roomId, description: nil, timeout: timeout) { response in
switch response {
case .success:
DispatchQueue.main.async {
self.locationSharingViewModel.stopLoading()
self.completion?()
}
break
case .failure(let error):
MXLog.error("[LocationSharingCoordinator] Failed to start live location sharing", context: error)
DispatchQueue.main.async {
self.locationSharingViewModel.stopLoading(error: .locationSharingError)
}
}
}
}
@@ -132,11 +132,9 @@ enum UserAgentParser {
if deviceInfoComponents[safe: 1]?.hasPrefix("Android") == true {
deviceOS = deviceInfoComponents[safe: 1]
} else if deviceInfoComponents.first == "Macintosh" {
var osFull = deviceInfoComponents[safe: 1]
osFull = osFull?.replacingOccurrences(of: "Intel ", with: "")
osFull = osFull?.replacingOccurrences(of: "Mac OS X", with: "macOS")
osFull = osFull?.replacingOccurrences(of: "_", with: ".")
deviceOS = osFull
deviceOS = "macOS"
} else if deviceInfoComponents.first?.hasPrefix("Windows") == true {
deviceOS = "Windows"
} else {
deviceOS = deviceInfoComponents.first
}
@@ -27,8 +27,8 @@ struct UserSessionInfo: Identifiable {
/// The device type used by the session
let deviceType: DeviceType
/// True to indicate that the session is verified
let isVerified: Bool
/// The current state of verification for the session.
let verificationState: VerificationState
/// The IP address where this device was last seen.
let lastSeenIP: String?
@@ -69,10 +69,84 @@ struct UserSessionInfo: Identifiable {
/// True to indicate that this is current user session
let isCurrent: Bool
/// Represents a verification state.
enum VerificationState {
/// The state is unknown (likely because the current session
/// hasn't been set up for cross-signing yet).
case unknown
/// The session has not yet been verified.
case unverified
/// The session has been verified.
case verified
}
}
// MARK: - Equatable
extension UserSessionInfo: Equatable {
static func == (lhs: UserSessionInfo, rhs: UserSessionInfo) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Mocks
extension UserSessionInfo {
static var mockPhone: UserSessionInfo {
UserSessionInfo(id: "1",
name: "Element Mobile: iOS",
deviceType: .mobile,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element iOS",
applicationVersion: "1.9.8",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 16.0.2",
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false)
}
static var mockPhoneUnverified: UserSessionInfo {
UserSessionInfo(id: "1",
name: "Element Mobile: iOS",
deviceType: .mobile,
verificationState: .unverified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element iOS",
applicationVersion: "1.9.8",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 16.0.2",
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false)
}
static var mockPhoneUnknownVerification: UserSessionInfo {
UserSessionInfo(id: "1",
name: "Element Mobile: iOS",
deviceType: .mobile,
verificationState: .unknown,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element iOS",
applicationVersion: "1.9.8",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 16.0.2",
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false)
}
}
@@ -38,14 +38,12 @@ struct DeviceAvatarView: View {
.clipShape(Circle())
// Verification badge
if let isVerified = viewData.isVerified {
Image(isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name)
.frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize))
.shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle())
.background(theme.colors.background)
.clipShape(Circle())
.offset(x: 10, y: 8)
}
Image(viewData.verificationImageName)
.frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize))
.shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle())
.background(theme.colors.background)
.clipShape(Circle())
.offset(x: 10, y: 8)
}
.frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize))
}
@@ -54,20 +52,20 @@ struct DeviceAvatarView: View {
struct DeviceAvatarViewListPreview: View {
var viewDataList: [DeviceAvatarViewData] {
[
DeviceAvatarViewData(deviceType: .desktop, isVerified: true),
DeviceAvatarViewData(deviceType: .web, isVerified: true),
DeviceAvatarViewData(deviceType: .mobile, isVerified: true),
DeviceAvatarViewData(deviceType: .unknown, isVerified: true)
DeviceAvatarViewData(deviceType: .desktop, verificationState: .verified),
DeviceAvatarViewData(deviceType: .web, verificationState: .verified),
DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified),
DeviceAvatarViewData(deviceType: .unknown, verificationState: .verified)
]
}
var body: some View {
HStack {
VStack(alignment: .center, spacing: 20) {
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, isVerified: true))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, isVerified: false))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, isVerified: true))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, isVerified: false))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, verificationState: .verified))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, verificationState: .unverified))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified))
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, verificationState: .unverified))
}
}
}
@@ -20,5 +20,18 @@ import SwiftUI
/// View data for DeviceAvatarView
struct DeviceAvatarViewData: Hashable {
let deviceType: DeviceType
let isVerified: Bool?
/// The current state of verification for the session.
let verificationState: UserSessionInfo.VerificationState
/// The name of the shield image to show for the device.
var verificationImageName: String {
switch verificationState {
case .verified:
return Asset.Images.userSessionVerified.name
case .unverified:
return Asset.Images.userSessionUnverified.name
case .unknown:
return Asset.Images.userSessionVerificationUnknown.name
}
}
}
@@ -26,22 +26,6 @@ struct UserSessionCardView: View {
var onViewDetailsAction: ((String) -> Void)?
var onLearnMoreAction: (() -> Void)?
private var verificationStatusImageName: String {
viewData.isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name
}
private var verificationStatusText: String {
viewData.isVerified ? VectorL10n.userSessionVerified : VectorL10n.userSessionUnverified
}
private var verificationStatusColor: Color {
viewData.isVerified ? theme.colors.accent : theme.colors.alert
}
private var verificationStatusAdditionalInfoText: String {
viewData.isVerified ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userSessionUnverifiedAdditionalInfo
}
private var backgroundShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 8)
}
@@ -53,27 +37,25 @@ struct UserSessionCardView: View {
var body: some View {
VStack(alignment: .center, spacing: 12) {
DeviceAvatarView(viewData: viewData.deviceAvatarViewData)
.accessibilityHidden(true)
Text(viewData.sessionName)
.font(theme.fonts.headline)
.foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.center)
HStack {
Image(verificationStatusImageName)
Text(verificationStatusText)
.font(theme.fonts.subheadline)
.foregroundColor(verificationStatusColor)
.multilineTextAlignment(.center)
}
Label(viewData.verificationStatusText, image: viewData.verificationStatusImageName)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors[keyPath: viewData.verificationStatusColor])
.multilineTextAlignment(.center)
if viewData.isCurrentSessionDisplayMode {
Text(verificationStatusAdditionalInfoText)
Text(viewData.verificationStatusAdditionalInfoText)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.multilineTextAlignment(.center)
} else {
InlineTextButton(verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) {
InlineTextButton(viewData.verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) {
onLearnMoreAction?()
}
.font(theme.fonts.footnote)
@@ -99,7 +81,7 @@ struct UserSessionCardView: View {
}
}
if viewData.isVerified == false {
if viewData.verificationState != .verified {
Button {
onVerifyAction?(viewData.sessionId)
} label: {
@@ -137,11 +119,11 @@ struct UserSessionCardViewPreview: View {
let viewData: UserSessionCardViewData
init(isCurrent: Bool = false) {
init(isCurrent: Bool = false, verificationState: UserSessionInfo.VerificationState = .unverified) {
let sessionInfo = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: verificationState,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
@@ -174,6 +156,13 @@ struct UserSessionCardView_Previews: PreviewProvider {
UserSessionCardViewPreview(isCurrent: true).theme(.dark).preferredColorScheme(.dark)
UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark)
UserSessionCardViewPreview(isCurrent: true, verificationState: .verified)
.theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview(verificationState: .verified)
.theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview(verificationState: .unknown)
.theme(.light).preferredColorScheme(.light)
}
}
}
@@ -14,7 +14,8 @@
// limitations under the License.
//
import Foundation
import DesignKit
import SwiftUI
/// View data for UserSessionCardView
struct UserSessionCardViewData {
@@ -26,7 +27,8 @@ struct UserSessionCardViewData {
let sessionName: String
let isVerified: Bool
/// The verification state used to render the card with.
let verificationState: UserSessionInfo.VerificationState
let lastActivityDateString: String?
@@ -37,16 +39,64 @@ struct UserSessionCardViewData {
/// Indicate if the current user session is shown and to adpat the layout
let isCurrentSessionDisplayMode: Bool
/// The name of the shield image to show the verification status.
var verificationStatusImageName: String {
switch verificationState {
case .verified:
return Asset.Images.userSessionVerified.name
case .unverified:
return Asset.Images.userSessionUnverified.name
case .unknown:
return Asset.Images.userSessionVerificationUnknown.name
}
}
/// The text to show alongside the verification shield image.
var verificationStatusText: String {
switch verificationState {
case .verified:
return VectorL10n.userSessionVerified
case .unverified:
return VectorL10n.userSessionUnverified
case .unknown:
return VectorL10n.userSessionVerificationUnknown
}
}
/// A key path to the theme colour to use for the verification status text.
var verificationStatusColor: KeyPath<ColorSwiftUI, Color> {
switch verificationState {
case .verified:
return \.accent
case .unverified:
return \.alert
case .unknown:
return \.secondaryContent
}
}
/// Further information to be shown to explain the verification state to the user.
var verificationStatusAdditionalInfoText: String {
switch verificationState {
case .verified:
return VectorL10n.userSessionVerifiedAdditionalInfo
case .unverified:
return VectorL10n.userSessionUnverifiedAdditionalInfo
case .unknown:
return VectorL10n.userSessionUnverifiedAdditionalInfo
}
}
init(sessionId: String,
sessionDisplayName: String?,
deviceType: DeviceType,
isVerified: Bool,
verificationState: UserSessionInfo.VerificationState,
lastActivityTimestamp: TimeInterval?,
lastSeenIP: String?,
isCurrentSessionDisplayMode: Bool = false) {
self.sessionId = sessionId
sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName)
self.isVerified = isVerified
self.verificationState = verificationState
var lastActivityDateString: String?
@@ -56,7 +106,7 @@ struct UserSessionCardViewData {
self.lastActivityDateString = lastActivityDateString
lastSeenIPInfo = lastSeenIP
deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: nil)
deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, verificationState: verificationState)
self.isCurrentSessionDisplayMode = isCurrentSessionDisplayMode
}
@@ -67,7 +117,7 @@ extension UserSessionCardViewData {
self.init(sessionId: sessionInfo.id,
sessionDisplayName: sessionInfo.name,
deviceType: sessionInfo.deviceType,
isVerified: sessionInfo.isVerified,
verificationState: sessionInfo.verificationState,
lastActivityTimestamp: sessionInfo.lastSeenTimestamp,
lastSeenIP: sessionInfo.lastSeenIP,
isCurrentSessionDisplayMode: sessionInfo.isCurrent)
@@ -24,6 +24,7 @@ struct UserSessionsFlowCoordinatorParameters {
final class UserSessionsFlowCoordinator: Coordinator, Presentable {
private let parameters: UserSessionsFlowCoordinatorParameters
private let allSessionsService: UserSessionsOverviewService
private let navigationRouter: NavigationRouterType
private var reauthenticationPresenter: ReauthenticationCoordinatorBridgePresenter?
@@ -41,6 +42,9 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsFlowCoordinatorParameters) {
self.parameters = parameters
let dataProvider = UserSessionsDataProvider(session: parameters.session)
allSessionsService = UserSessionsOverviewService(dataProvider: dataProvider)
navigationRouter = parameters.router
errorPresenter = MXKErrorAlertPresentation()
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable())
@@ -59,22 +63,23 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
}
private func createUserSessionsOverviewCoordinator() -> UserSessionsOverviewCoordinator {
let parameters = UserSessionsOverviewCoordinatorParameters(session: parameters.session)
let parameters = UserSessionsOverviewCoordinatorParameters(session: parameters.session,
service: allSessionsService)
let coordinator = UserSessionsOverviewCoordinator(parameters: parameters)
coordinator.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .verifyCurrentSession:
self.showCompleteSecurity()
case let .renameSession(sessionInfo):
break
self.showRenameSessionScreen(for: sessionInfo)
case let .logoutOfSession(sessionInfo):
self.showLogoutConfirmation(for: sessionInfo)
case let .openSessionOverview(sessionInfo: sessionInfo):
self.openSessionOverview(sessionInfo: sessionInfo)
case let .openOtherSessions(sessionInfos: sessionInfos, filter: filter):
self.openOtherSessions(sessionInfos: sessionInfos,
filterBy: filter,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
self.openOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
case .linkDevice:
self.openQRLoginScreen()
}
@@ -99,8 +104,14 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
switch result {
case let .openSessionDetails(sessionInfo: sessionInfo):
self.openSessionDetails(sessionInfo: sessionInfo)
case let .verifySession(sessionInfo):
if sessionInfo.isCurrent {
self.showCompleteSecurity()
} else {
self.showVerification(for: sessionInfo)
}
case let .renameSession(sessionInfo):
break
self.showRenameSessionScreen(for: sessionInfo)
case let .logoutOfSession(sessionInfo):
self.showLogoutConfirmation(for: sessionInfo)
}
@@ -125,11 +136,13 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator {
let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session,
sessionInfo: sessionInfo)
sessionInfo: sessionInfo,
sessionsOverviewDataPublisher: allSessionsService.overviewDataPublisher)
return UserSessionOverviewCoordinator(parameters: parameters)
}
private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
let title = filter == .all ? VectorL10n.userSessionsOverviewOtherSessionsSectionTitle : VectorL10n.userOtherSessionSecurityRecommendationTitle
let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos,
filterBy: filter,
title: title)
@@ -204,7 +217,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
self.stopLoading()
guard response.isSuccess else {
MXLog.debug("[LogoutDeviceService] Delete device (\(sessionInfo.id) failed")
MXLog.debug("[UserSessionsFlowCoordinator] Delete device (\(sessionInfo.id)) failed")
if let error = response.error {
self.errorPresenter.presentError(from: self.toPresentable(), forError: error, animated: true, handler: { })
} else {
@@ -218,6 +231,69 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
}
}
private func showRenameSessionScreen(for sessionInfo: UserSessionInfo) {
let parameters = UserSessionNameCoordinatorParameters(session: parameters.session, sessionInfo: sessionInfo)
let coordinator = UserSessionNameCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return }
switch result {
case .sessionNameUpdated:
self.allSessionsService.updateOverviewData { [weak self] _ in
self?.navigationRouter.dismissModule(animated: true, completion: nil)
self?.remove(childCoordinator: coordinator)
}
case .cancel:
self.navigationRouter.dismissModule(animated: true, completion: nil)
self.remove(childCoordinator: coordinator)
}
}
add(childCoordinator: coordinator)
let modalRouter = NavigationRouter(navigationController: RiotNavigationController())
modalRouter.setRootModule(coordinator)
coordinator.start()
navigationRouter.present(modalRouter, animated: true)
}
/// Shows a prompt to the user that it is not possible to verify
/// another session until the current session has been verified.
private func showCannotVerifyOtherSessionPrompt() {
let alert = UIAlertController(title: VectorL10n.securitySettingsCompleteSecurityAlertTitle,
message: VectorL10n.securitySettingsCompleteSecurityAlertMessage,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: VectorL10n.later, style: .cancel))
alert.addAction(UIAlertAction(title: VectorL10n.ok, style: .default) { [weak self] _ in
self?.showCompleteSecurity()
})
navigationRouter.present(alert, animated: true)
}
/// Shows the Complete Security modal for the user to verify their current session.
private func showCompleteSecurity() {
AppDelegate.theDelegate().presentCompleteSecurity(for: parameters.session)
}
/// Shows the verification screen for the specified session.
private func showVerification(for sessionInfo: UserSessionInfo) {
if sessionInfo.verificationState == .unknown {
showCannotVerifyOtherSessionPrompt()
return
}
let coordinator = UserVerificationCoordinator(presenter: toPresentable(),
session: parameters.session,
userId: parameters.session.myUserId,
userDisplayName: nil,
deviceId: sessionInfo.id)
coordinator.delegate = self
add(childCoordinator: coordinator)
coordinator.start()
}
/// Pops back to the root coordinator in the session management flow.
private func popToSessionsOverview() {
guard let sessionsOverviewCoordinator = sessionsOverviewCoordinator else { return }
@@ -263,3 +339,30 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
navigationRouter.toPresentable()
}
}
// MARK: CrossSigningSetupCoordinatorDelegate
extension UserSessionsFlowCoordinator: CrossSigningSetupCoordinatorDelegate {
func crossSigningSetupCoordinatorDidComplete(_ coordinator: CrossSigningSetupCoordinatorType) {
// The service is listening for changes so there's nothing to do here.
remove(childCoordinator: coordinator)
}
func crossSigningSetupCoordinatorDidCancel(_ coordinator: CrossSigningSetupCoordinatorType) {
remove(childCoordinator: coordinator)
}
func crossSigningSetupCoordinator(_ coordinator: CrossSigningSetupCoordinatorType, didFailWithError error: Error) {
remove(childCoordinator: coordinator)
errorPresenter.presentError(from: toPresentable(), forError: error, animated: true, handler: { })
}
}
// MARK: UserVerificationCoordinatorDelegate
extension UserSessionsFlowCoordinator: UserVerificationCoordinatorDelegate {
func userVerificationCoordinatorDidComplete(_ coordinator: UserVerificationCoordinatorType) {
// The service is listening for changes so there's nothing to do here.
remove(childCoordinator: coordinator)
}
}
@@ -23,7 +23,8 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case all
case inactiveSessions
case unverifiedSessions
@@ -35,13 +36,17 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
/// A list of screen state definitions
static var allCases: [MockUserOtherSessionsScreenState] {
// Each of the presence statuses
[.inactiveSessions, .unverifiedSessions]
[.all, .inactiveSessions, .unverifiedSessions]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: UserOtherSessionsViewModel
switch self {
case .all:
viewModel = UserOtherSessionsViewModel(sessionInfos: allSessions(),
filter: .all,
title: VectorL10n.userSessionsOverviewOtherSessionsSectionTitle)
case .inactiveSessions:
viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(),
filter: .inactive,
@@ -51,7 +56,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
filter: .unverified,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
}
// can simulate service and viewModel actions here if needs be.
return (
@@ -64,7 +69,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
[UserSessionInfo(id: "0",
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: nil,
@@ -80,7 +85,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: true,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: nil,
@@ -96,7 +101,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
UserSessionInfo(id: "2",
name: "Firefox on Windows",
deviceType: .web,
isVerified: true,
verificationState: .verified,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000,
applicationName: nil,
@@ -112,7 +117,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
UserSessionInfo(id: "3",
name: "Android",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000,
applicationName: nil,
@@ -131,7 +136,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
[UserSessionInfo(id: "0",
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: nil,
@@ -147,7 +152,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: nil,
@@ -161,4 +166,103 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isActive: true,
isCurrent: false)]
}
private func allSessions() -> [UserSessionInfo] {
[UserSessionInfo(id: "0",
name: "iOS",
deviceType: .mobile,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 500_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false),
UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false),
UserSessionInfo(id: "2",
name: "Firefox on Windows",
deviceType: .web,
verificationState: .verified,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false),
UserSessionInfo(id: "3",
name: "Android",
deviceType: .mobile,
verificationState: .unverified,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false),
UserSessionInfo(id: "4",
name: "iOS",
deviceType: .mobile,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 11_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false),
UserSessionInfo(id: "5",
name: "macOS",
deviceType: .desktop,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 20_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: false,
isCurrent: false)]
}
}
@@ -43,4 +43,10 @@ class UserOtherSessionsUITests: MockScreenTestCase {
XCTAssertTrue(app.buttons["iOS, Unverified · Your current session"].exists)
}
func test_whenOtherSessionsWithAllSessionFilterPresented_correctHeaderDisplayed() {
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo].exists)
}
}
@@ -49,11 +49,26 @@ class UserOtherSessionsViewModelTests: XCTestCase {
XCTAssertEqual(sut.state, expectedState)
}
func test_whenModelCreated_withAllFilter_viewStateIsCorrect() {
let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos,
filter: .all,
title: "Title")
let expectedHeader = UserOtherSessionsHeaderViewData(title: nil,
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
iconName: nil)
let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
let expectedState = UserOtherSessionsViewState(title: "Title",
sections: [.sessionItems(header: expectedHeader, items: expectedItems)])
XCTAssertEqual(sut.state, expectedState)
}
private func createUserSessionInfo(sessionId: String) -> UserSessionInfo {
UserSessionInfo(id: sessionId,
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: nil,
@@ -72,16 +72,15 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
case .inactive:
return sessionInfos.filter { !$0.isActive }
case .unverified:
return sessionInfos.filter { !$0.isVerified }
return sessionInfos.filter { $0.verificationState != .verified }
}
}
private func createHeaderData(filter: OtherUserSessionsFilter) -> UserOtherSessionsHeaderViewData {
switch filter {
case .all:
// TODO:
return UserOtherSessionsHeaderViewData(title: nil,
subtitle: "",
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
iconName: nil)
case .inactive:
return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
@@ -39,6 +39,7 @@ struct UserOtherSessionsHeaderView: View {
.background(theme.colors.background)
.clipShape(backgroundShape)
.shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape)
.padding(.trailing, 16)
}
VStack(alignment: .leading, spacing: 0, content: {
if let title = viewData.title {
@@ -52,7 +53,6 @@ struct UserOtherSessionsHeaderView: View {
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 20.0)
})
.padding(.leading, 16)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
@@ -62,18 +62,37 @@ struct UserOtherSessionsHeaderView: View {
// MARK: - Previews
struct UserOtherSessionsHeaderView_Previews: PreviewProvider {
private static let headerWithTitleSubtitleIcon = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
iconName: Asset.Images.userOtherSessionsInactive.name)
private static let headerWithSubtitle = UserOtherSessionsHeaderViewData(title: nil,
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
iconName: nil)
private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
iconName: Asset.Images.userOtherSessionsInactive.name)
static var previews: some View {
Group {
UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData)
.theme(.light)
.preferredColorScheme(.light)
UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData)
.theme(.dark)
.preferredColorScheme(.dark)
VStack {
Divider()
UserOtherSessionsHeaderView(viewData: self.headerWithTitleSubtitleIcon)
Divider()
UserOtherSessionsHeaderView(viewData: self.headerWithSubtitle)
Divider()
}
.theme(.light)
.preferredColorScheme(.light)
VStack {
Divider()
UserOtherSessionsHeaderView(viewData: self.headerWithTitleSubtitleIcon)
Divider()
UserOtherSessionsHeaderView(viewData: self.headerWithSubtitle)
Divider()
}
.theme(.dark)
.preferredColorScheme(.dark)
}
}
}
@@ -44,7 +44,7 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
sessionInfo = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
@@ -61,7 +61,7 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
sessionInfo = UserSessionInfo(id: "3",
name: "Android",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
@@ -120,7 +120,7 @@ class UserSessionDetailsViewModelTests: XCTestCase {
UserSessionInfo(id: id,
name: name,
deviceType: deviceType,
isVerified: isVerified,
verificationState: isVerified ? .verified : .unverified,
lastSeenIP: lastSeenIP,
lastSeenTimestamp: lastSeenTimestamp,
applicationName: applicationName,
@@ -0,0 +1,98 @@
//
// 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 UserSessionNameCoordinatorParameters {
let session: MXSession
let sessionInfo: UserSessionInfo
}
final class UserSessionNameCoordinator: Coordinator, Presentable {
private let parameters: UserSessionNameCoordinatorParameters
private let userSessionNameHostingController: UIViewController
private var userSessionNameViewModel: UserSessionNameViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((UserSessionNameCoordinatorResult) -> Void)?
init(parameters: UserSessionNameCoordinatorParameters) {
self.parameters = parameters
let viewModel = UserSessionNameViewModel(sessionInfo: parameters.sessionInfo)
let view = UserSessionName(viewModel: viewModel.context)
userSessionNameViewModel = viewModel
userSessionNameHostingController = VectorHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userSessionNameHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[UserSessionNameCoordinator] did start.")
userSessionNameViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[UserSessionNameCoordinator] UserSessionNameViewModel did complete with result: \(result).")
switch result {
case .updateName(let newName):
self.updateName(newName)
case .cancel:
self.completion?(.cancel)
}
}
}
func toPresentable() -> UIViewController { userSessionNameHostingController }
// MARK: - Private
/// Updates the name of the device, completing the screen's presentation if successful.
private func updateName(_ newName: String) {
startLoading()
parameters.session.matrixRestClient.setDeviceName(newName, forDevice: parameters.sessionInfo.id) { [weak self] response in
guard let self = self else { return }
guard response.isSuccess else {
MXLog.debug("[UserSessionNameCoordinator] Rename device (\(self.parameters.sessionInfo.id)) failed")
self.userSessionNameViewModel.processError(response.error as NSError?)
return
}
self.stopLoading()
self.completion?(.sessionNameUpdated)
}
}
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) {
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,51 @@
//
// 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 MockUserSessionNameScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case initialName
case empty
case changedName
/// The associated screen
var screenType: Any.Type {
UserSessionName.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: UserSessionNameViewModel
switch self {
case .initialName:
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
case .empty:
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
viewModel.state.bindings.sessionName = ""
case .changedName:
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
viewModel.state.bindings.sessionName = "iPhone SE"
}
return ([viewModel], AnyView(UserSessionName(viewModel: viewModel.context)))
}
}
@@ -0,0 +1,44 @@
//
// 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 RiotSwiftUI
import XCTest
class UserSessionNameUITests: MockScreenTestCase {
func testUserSessionNameInitialState() {
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.initialName.title)
let doneButton = app.buttons[VectorL10n.done]
XCTAssertTrue(doneButton.exists)
XCTAssertFalse(doneButton.isEnabled)
}
func testUserSessionNameEmptyState() {
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.empty.title)
let doneButton = app.buttons[VectorL10n.done]
XCTAssertTrue(doneButton.exists)
XCTAssertFalse(doneButton.isEnabled)
}
func testUserSessionNameChangedState() {
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.changedName.title)
let doneButton = app.buttons[VectorL10n.done]
XCTAssertTrue(doneButton.exists)
XCTAssertTrue(doneButton.isEnabled)
}
}
@@ -0,0 +1,51 @@
//
// 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 XCTest
@testable import RiotSwiftUI
class UserSessionNameViewModelTests: XCTestCase {
var viewModel: UserSessionNameViewModelProtocol!
var context: UserSessionNameViewModelType.Context!
override func setUpWithError() throws {
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
context = viewModel.context
}
func testClearingName() {
// Given an unedited name.
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.")
// When clearing the name.
context.sessionName = ""
// Then the done button should remain be disabled.
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name is empty.")
}
func testChangingName() {
// Given an unedited name.
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.")
// When changing the name.
context.sessionName = "Alice's iPhone"
// Then the done button should be enabled.
XCTAssertTrue(context.viewState.canUpdateName, "The done button should be enabled when the name has been changed.")
}
}
@@ -0,0 +1,64 @@
//
// 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
// MARK: - Coordinator
enum UserSessionNameCoordinatorResult {
/// The user cancelled the rename operation.
case cancel
/// The user successfully updated the name of the session.
case sessionNameUpdated
}
// MARK: View model
enum UserSessionNameViewModelResult {
/// The user cancelled the rename operation.
case cancel
/// Update the session name to the supplied string.
case updateName(String)
}
// MARK: View
struct UserSessionNameViewState: BindableState {
var bindings: UserSessionNameBindings
/// The current name of the session before any updates are made.
let currentName: String
/// Whether or not to allow the user to update the session name.
var canUpdateName: Bool {
!bindings.sessionName.isEmpty && bindings.sessionName != currentName
}
}
struct UserSessionNameBindings {
/// The name input by the user.
var sessionName: String
/// The currently displayed alert's info value otherwise `nil`.
var alertInfo: AlertInfo<Int>?
}
enum UserSessionNameViewAction {
/// The user tapped the done button to update the session name.
case done
/// The user tapped the cancel button.
case cancel
/// The user tapped the Learn More link.
case learnMore
}
@@ -0,0 +1,45 @@
//
// 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 UserSessionNameViewModelType = StateStoreViewModel<UserSessionNameViewState, UserSessionNameViewAction>
class UserSessionNameViewModel: UserSessionNameViewModelType, UserSessionNameViewModelProtocol {
var completion: ((UserSessionNameViewModelResult) -> Void)?
init(sessionInfo: UserSessionInfo) {
super.init(initialViewState: UserSessionNameViewState(bindings: .init(sessionName: sessionInfo.name ?? ""),
currentName: sessionInfo.name ?? ""))
}
// MARK: - Public
override func process(viewAction: UserSessionNameViewAction) {
switch viewAction {
case .done:
completion?(.updateName(state.bindings.sessionName))
case .cancel:
completion?(.cancel)
case .learnMore:
#warning("To be implemented as part of PSG-714.")
}
}
func processError(_ error: NSError?) {
state.bindings.alertInfo = AlertInfo(error: error)
}
}
@@ -0,0 +1,26 @@
//
// 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 UserSessionNameViewModelProtocol {
var completion: ((UserSessionNameViewModelResult) -> Void)? { get set }
var context: UserSessionNameViewModelType.Context { get }
/// Update the view model to show that an error has occurred.
/// - Parameter error: The error to be displayed or `nil` to display a generic alert.
func processError(_ error: NSError?)
}
@@ -0,0 +1,78 @@
import SwiftUI
struct UserSessionName: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@ObservedObject var viewModel: UserSessionNameViewModel.Context
var body: some View {
List {
SwiftUI.Section {
TextField(VectorL10n.manageSessionName, text: $viewModel.sessionName)
.autocapitalization(.words)
.listRowBackground(theme.colors.background)
.introspectTextField {
$0.becomeFirstResponder()
$0.clearButtonMode = .whileEditing
}
} header: {
Text(VectorL10n.manageSessionName)
.foregroundColor(theme.colors.secondaryContent)
} footer: {
textFieldFooter
}
}
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.listStyle(.grouped)
.listBackgroundColor(theme.colors.system)
.navigationTitle(VectorL10n.manageSessionRename)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.accentColor(theme.colors.accent)
}
private var textFieldFooter: some View {
VStack(alignment: .leading, spacing: 16) {
Text(VectorL10n.manageSessionNameHint)
.foregroundColor(theme.colors.secondaryContent)
InlineTextButton(VectorL10n.manageSessionNameInfo("%@"),
tappableText: VectorL10n.manageSessionNameInfoLink) {
viewModel.send(viewAction: .learnMore)
}
.foregroundColor(theme.colors.secondaryContent)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(VectorL10n.done) {
viewModel.send(viewAction: .done)
}
.disabled(!viewModel.viewState.canUpdateName)
}
}
}
// MARK: - Previews
struct UserSessionName_Previews: PreviewProvider {
static let stateRenderer = MockUserSessionNameScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light)
.preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark)
.preferredColorScheme(.dark)
}
}
@@ -14,12 +14,14 @@
// limitations under the License.
//
import Combine
import CommonKit
import SwiftUI
struct UserSessionOverviewCoordinatorParameters {
let session: MXSession
let sessionInfo: UserSessionInfo
let sessionsOverviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
}
final class UserSessionOverviewCoordinator: Coordinator, Presentable {
@@ -42,7 +44,9 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
self.parameters = parameters
let service = UserSessionOverviewService(session: parameters.session, sessionInfo: parameters.sessionInfo)
viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo, service: service)
viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo,
service: service,
sessionsOverviewDataPublisher: parameters.sessionsOverviewDataPublisher)
hostingController = VectorHostingController(rootView: UserSessionOverview(viewModel: viewModel.context))
hostingController.vc_setLargeTitleDisplayMode(.never)
@@ -60,8 +64,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
MXLog.debug("[UserSessionOverviewCoordinator] UserSessionOverviewViewModel did complete with result: \(result).")
switch result {
case .verifyCurrentSession:
break // TODO:
case let .verifySession(sessionInfo):
self.completion?(.verifySession(sessionInfo))
case let .showSessionDetails(sessionInfo: sessionInfo):
self.completion?(.openSessionDetails(sessionInfo: sessionInfo))
case let .renameSession(sessionInfo):
@@ -51,7 +51,7 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
session = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
@@ -69,14 +69,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
session = UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: true,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
deviceOS: "macOS",
lastSeenIPLocation: nil,
clientName: "Electron",
clientVersion: "20.1.1",
@@ -87,14 +87,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
session = UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: true,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
deviceOS: "macOS",
lastSeenIPLocation: nil,
clientName: "My Mac",
clientVersion: "1.0.0",
@@ -105,14 +105,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
session = UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: true,
verificationState: .verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
deviceOS: "macOS",
lastSeenIPLocation: nil,
clientName: "My Mac",
clientVersion: "1.0.0",
@@ -21,15 +21,16 @@ import XCTest
class UserSessionOverviewViewModelTests: XCTestCase {
func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() {
let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService())
let sessionInfo = createUserSessionInfo()
let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService())
XCTAssertEqual(sut.state.isPusherEnabled, nil)
var modelResult: UserSessionOverviewViewModelResult?
sut.completion = { result in
modelResult = result
}
sut.process(viewAction: .verifyCurrentSession)
XCTAssertEqual(modelResult, .verifyCurrentSession)
sut.process(viewAction: .verifySession)
XCTAssertEqual(modelResult, .verifySession(sessionInfo))
}
func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() {
@@ -88,7 +89,7 @@ class UserSessionOverviewViewModelTests: XCTestCase {
UserSessionInfo(id: "session",
name: "iOS",
deviceType: .mobile,
isVerified: false,
verificationState: .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: "Element iOS",
@@ -20,6 +20,7 @@ import Foundation
enum UserSessionOverviewCoordinatorResult {
case openSessionDetails(sessionInfo: UserSessionInfo)
case verifySession(UserSessionInfo)
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
}
@@ -28,7 +29,7 @@ enum UserSessionOverviewCoordinatorResult {
enum UserSessionOverviewViewModelResult: Equatable {
case showSessionDetails(sessionInfo: UserSessionInfo)
case verifyCurrentSession
case verifySession(UserSessionInfo)
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
}
@@ -36,7 +37,7 @@ enum UserSessionOverviewViewModelResult: Equatable {
// MARK: View
struct UserSessionOverviewViewState: BindableState {
let cardViewData: UserSessionCardViewData
var cardViewData: UserSessionCardViewData
let isCurrentSession: Bool
var isPusherEnabled: Bool?
var remotelyTogglingPushersAvailable: Bool
@@ -44,7 +45,7 @@ struct UserSessionOverviewViewState: BindableState {
}
enum UserSessionOverviewViewAction {
case verifyCurrentSession
case verifySession
case viewSessionDetails
case togglePushNotifications
case renameSession
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias UserSessionOverviewViewModelType = StateStoreViewModel<UserSessionOverviewViewState, UserSessionOverviewViewAction>
@@ -26,7 +27,13 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
// MARK: - Setup
init(sessionInfo: UserSessionInfo, service: UserSessionOverviewServiceProtocol) {
init(sessionInfo: UserSessionInfo,
service: UserSessionOverviewServiceProtocol,
sessionsOverviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never> = .init(.init(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))) {
self.sessionInfo = sessionInfo
self.service = service
@@ -39,6 +46,21 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
super.init(initialViewState: state)
startObservingService()
sessionsOverviewDataPublisher.sink { [weak self] overviewData in
guard let self = self else { return }
var updatedInfo: UserSessionInfo?
if let currentSession = overviewData.currentSession, currentSession.id == sessionInfo.id {
updatedInfo = currentSession
} else if let otherSession = overviewData.otherSessions.first(where: { $0.id == sessionInfo.id }) {
updatedInfo = otherSession
}
guard let updatedInfo = updatedInfo else { return }
self.state.cardViewData = UserSessionCardViewData(sessionInfo: updatedInfo)
}
.store(in: &cancellables)
}
private func startObservingService() {
@@ -62,8 +84,8 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
override func process(viewAction: UserSessionOverviewViewAction) {
switch viewAction {
case .verifyCurrentSession:
completion?(.verifyCurrentSession)
case .verifySession:
completion?(.verifySession(sessionInfo))
case .viewSessionDetails:
completion?(.showSessionDetails(sessionInfo: sessionInfo))
case .togglePushNotifications:
@@ -24,7 +24,7 @@ struct UserSessionOverview: View {
var body: some View {
ScrollView {
UserSessionCardView(viewData: viewModel.viewState.cardViewData, onVerifyAction: { _ in
viewModel.send(viewAction: .verifyCurrentSession)
viewModel.send(viewAction: .verifySession)
},
onViewDetailsAction: { _ in
viewModel.send(viewAction: .viewSessionDetails)
@@ -47,6 +47,7 @@ struct UserSessionOverview: View {
SwiftUI.Section {
UserSessionOverviewItem(title: VectorL10n.manageSessionSignOut,
alignment: .center,
isDestructive: true) {
viewModel.send(viewAction: .logoutOfSession)
}
@@ -66,8 +67,11 @@ struct UserSessionOverview: View {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis")
.padding(.horizontal, 4)
.padding(.vertical, 12)
}
.offset(x: 4) // Re-align the symbol after applying padding.
}
}
.accentColor(theme.colors.accent)
@@ -20,6 +20,7 @@ struct UserSessionOverviewItem: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let title: String
var alignment: Alignment = .leading
var showsChevron = false
var isDestructive = false
var onBackgroundTap: (() -> Void)?
@@ -32,7 +33,7 @@ struct UserSessionOverviewItem: View {
Text(title)
.font(theme.fonts.body)
.foregroundColor(textColor)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, alignment: alignment)
if showsChevron {
Image(Asset.Images.chevron.name)
@@ -19,6 +19,7 @@ import SwiftUI
struct UserSessionsOverviewCoordinatorParameters {
let session: MXSession
let service: UserSessionsOverviewService
}
final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
@@ -36,10 +37,9 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsOverviewCoordinatorParameters) {
self.parameters = parameters
service = parameters.service
let dataProvider = UserSessionsDataProvider(session: parameters.session)
service = UserSessionsOverviewService(dataProvider: dataProvider)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: parameters.service)
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
hostingViewController.vc_setLargeTitleDisplayMode(.never)
@@ -60,7 +60,7 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
case let .showOtherSessions(sessionInfos: sessionInfos, filter: filter):
self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
case .verifyCurrentSession:
self.startVerifyCurrentSession()
self.completion?(.verifyCurrentSession)
case .renameSession(let sessionInfo):
self.completion?(.renameSession(sessionInfo))
case .logoutOfSession(let sessionInfo):
@@ -44,6 +44,16 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
session.crypto.device(withDeviceId: deviceId, ofUser: userId)
}
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState {
guard let deviceInfo = deviceInfo else { return .unknown }
guard session.crypto?.crossSigning?.canCrossSign == true else {
return deviceInfo.deviceId == session.myDeviceId ? .unverified : .unknown
}
return deviceInfo.trustLevel.isVerified ? .verified : .unverified
}
func accountData(for eventType: String) -> [AnyHashable: Any]? {
session.accountData.accountData(forEventType: eventType)
}
@@ -28,6 +28,8 @@ protocol UserSessionsDataProviderProtocol {
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo?
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState
func accountData(for eventType: String) -> [AnyHashable: Any]?
func qrLoginAvailable() async throws -> Bool
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
import MatrixSDK
class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
@@ -22,20 +22,22 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400
private let dataProvider: UserSessionsDataProviderProtocol
private var cancellables: Set<AnyCancellable> = []
private(set) var overviewData: UserSessionsOverviewData
private(set) var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
private(set) var sessionInfos: [UserSessionInfo]
init(dataProvider: UserSessionsDataProviderProtocol) {
self.dataProvider = dataProvider
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
sessionInfos = []
setupInitialOverviewData()
listenForSessionUpdates()
}
// MARK: - Public
@@ -47,9 +49,10 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
self.sessionInfos = self.sortedSessionInfos(from: devices)
Task { @MainActor in
let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable()
self.overviewData = self.sessionsOverviewData(from: self.sessionInfos,
linkDeviceEnabled: linkDeviceEnabled ?? false)
completion(.success(self.overviewData))
let overviewData = self.sessionsOverviewData(from: self.sessionInfos,
linkDeviceEnabled: linkDeviceEnabled ?? false)
self.overviewDataPublisher.send(overviewData)
completion(.success(overviewData))
}
case .failure(let error):
completion(.failure(error))
@@ -58,25 +61,43 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
if overviewData.currentSession?.id == sessionId {
return overviewData.currentSession
if currentSession?.id == sessionId {
return currentSession
}
return overviewData.otherSessions.first(where: { $0.id == sessionId })
return otherSessions.first(where: { $0.id == sessionId })
}
// MARK: - Private
private func listenForSessionUpdates() {
NotificationCenter.default.publisher(for: .MXDeviceInfoTrustLevelDidChange)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .MXDeviceListDidUpdateUsersDevices)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .MXCrossSigningInfoTrustLevelDidChange)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
}
private func setupInitialOverviewData() {
guard let currentSessionInfo = getCurrentSessionInfo() else {
return
}
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.verificationState == .verified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [],
linkDeviceEnabled: false))
}
private func getCurrentSessionInfo() -> UserSessionInfo? {
@@ -96,14 +117,15 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private func sessionsOverviewData(from allSessions: [UserSessionInfo],
linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified },
unverifiedSessions: allSessions.filter { $0.verificationState != .verified },
inactiveSessions: allSessions.filter { !$0.isActive },
otherSessions: allSessions.filter { !$0.isCurrent },
linkDeviceEnabled: linkDeviceEnabled)
}
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false
let deviceInfo = deviceInfo(for: device.deviceId)
let verificationState = dataProvider.verificationState(for: deviceInfo)
let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId
let appData = dataProvider.accountData(for: eventType)
@@ -122,7 +144,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
return UserSessionInfo(withDevice: device,
applicationData: appData as? [String: String],
userAgent: userAgent,
isSessionVerified: isSessionVerified,
verificationState: verificationState,
isActive: isSessionActive,
isCurrent: isCurrentSession)
}
@@ -140,13 +162,13 @@ extension UserSessionInfo {
init(withDevice device: MXDevice,
applicationData: [String: String]?,
userAgent: UserAgent?,
isSessionVerified: Bool,
verificationState: VerificationState,
isActive: Bool,
isCurrent: Bool) {
self.init(id: device.deviceId,
name: device.displayName,
deviceType: userAgent?.deviceType ?? .unknown,
isVerified: isSessionVerified,
verificationState: verificationState,
lastSeenIP: device.lastSeenIp,
lastSeenTimestamp: device.lastSeenTs > 0 ? TimeInterval(device.lastSeenTs / 1000) : nil,
applicationName: applicationData?["name"],
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
enum Mode {
@@ -27,17 +27,17 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private let mode: Mode
var overviewData: UserSessionsOverviewData
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
var sessionInfos = [UserSessionInfo]()
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
}
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
@@ -46,47 +46,47 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
switch mode {
case .noOtherSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
case .onlyUnverifiedSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions + [currentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions,
linkDeviceEnabled: false)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: unverifiedSessions + [mockCurrentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions,
linkDeviceEnabled: false))
case .onlyInactiveSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions,
linkDeviceEnabled: false)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions,
linkDeviceEnabled: false))
default:
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions,
linkDeviceEnabled: true)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions,
linkDeviceEnabled: true))
}
completion(.success(overviewData))
completion(.success(overviewDataPublisher.value))
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
overviewData.otherSessions.first { $0.id == sessionId }
otherSessions.first { $0.id == sessionId }
}
// MARK: - Private
private var currentSession: UserSessionInfo {
private var mockCurrentSession: UserSessionInfo {
UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: mode == .currentSessionVerified,
verificationState: mode == .currentSessionVerified ? .verified : .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
@@ -105,14 +105,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
[UserSessionInfo(id: "1 verified: \(verified) active: \(active)",
name: "macOS verified: \(verified) active: \(active)",
deviceType: .desktop,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
deviceOS: "macOS",
lastSeenIPLocation: nil,
clientName: "Electron",
clientVersion: "20.0.0",
@@ -121,14 +121,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
UserSessionInfo(id: "2 verified: \(verified) active: \(active)",
name: "Firefox on Windows verified: \(verified) active: \(active)",
deviceType: .web,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: "Element Web",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Windows 10",
deviceOS: "Windows",
lastSeenIPLocation: nil,
clientName: "Firefox",
clientVersion: "39.0",
@@ -137,7 +137,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
UserSessionInfo(id: "3 verified: \(verified) active: \(active)",
name: "Android verified: \(verified) active: \(active)",
deviceType: .mobile,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
struct UserSessionsOverviewData {
let currentSession: UserSessionInfo?
@@ -25,10 +25,23 @@ struct UserSessionsOverviewData {
}
protocol UserSessionsOverviewServiceProtocol {
var overviewData: UserSessionsOverviewData { get }
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never> { get }
var sessionInfos: [UserSessionInfo] { get }
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) -> Void
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo?
}
extension UserSessionsOverviewServiceProtocol {
/// The user's current session.
var currentSession: UserSessionInfo? { overviewDataPublisher.value.currentSession }
/// Any unverified sessions on the user's account.
var unverifiedSessions: [UserSessionInfo] { overviewDataPublisher.value.unverifiedSessions }
/// Any inactive sessions on the user's account (not seen for a while).
var inactiveSessions: [UserSessionInfo] { overviewDataPublisher.value.inactiveSessions }
/// Any sessions that are verified and have been seen recently.
var otherSessions: [UserSessionInfo] { overviewDataPublisher.value.otherSessions }
/// Whether it is possible to link a new device via a QR code.
var linkDeviceEnabled: Bool { overviewDataPublisher.value.linkDeviceEnabled }
}
@@ -73,4 +73,18 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertFalse(linkDeviceButton.exists)
}
}
func testWhenMoreThan5OtherSessionsThenViewAllButtonVisible() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionUnverified.title)
app.swipeUp()
XCTAssertTrue(app.buttons["ViewAllButton"].exists)
}
func testWhenLessThan5OtherSessionsThenViewAllButtonHidden() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyUnverifiedSessions.title)
app.swipeUp()
XCTAssertFalse(app.buttons["ViewAllButton"].exists)
}
}
@@ -52,9 +52,15 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
viewModel.process(viewAction: .verifyCurrentSession)
XCTAssertEqual(result, .verifyCurrentSession)
result = nil
viewModel.process(viewAction: .viewAllInactiveSessions)
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive))
result = nil
viewModel.process(viewAction: .viewAllOtherSessions)
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .all))
result = nil
viewModel.process(viewAction: .linkDevice)
XCTAssertEqual(result, .linkDevice)
}
@@ -70,7 +76,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
result = action
}
guard let currentSession = service.overviewData.currentSession else {
guard let currentSession = service.currentSession else {
XCTFail("The current session should be valid at this point")
return
}
@@ -78,7 +84,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
viewModel.process(viewAction: .viewCurrentSessionDetails)
XCTAssertEqual(result, .showCurrentSessionOverview(sessionInfo: currentSession))
guard let randomSession = service.overviewData.otherSessions.randomElement() else {
guard let randomSession = service.otherSessions.randomElement() else {
XCTFail("There should be other sessions")
return
}
@@ -19,6 +19,7 @@ import Foundation
// MARK: - Coordinator
enum UserSessionsOverviewCoordinatorResult {
case verifyCurrentSession
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case openSessionOverview(sessionInfo: UserSessionInfo)
@@ -20,7 +20,7 @@ typealias UserSessionsOverviewViewModelType = StateStoreViewModel<UserSessionsOv
class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSessionsOverviewViewModelProtocol {
private let userSessionsOverviewService: UserSessionsOverviewServiceProtocol
var completion: ((UserSessionsOverviewViewModelResult) -> Void)?
init(userSessionsOverviewService: UserSessionsOverviewServiceProtocol) {
@@ -28,7 +28,12 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
super.init(initialViewState: .init())
updateViewState(with: userSessionsOverviewService.overviewData)
userSessionsOverviewService.overviewDataPublisher.sink { [weak self] overviewData in
self?.updateViewState(with: overviewData)
}
.store(in: &cancellables)
updateViewState(with: userSessionsOverviewService.overviewDataPublisher.value)
}
// MARK: - Public
@@ -40,19 +45,19 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
case .verifyCurrentSession:
completion?(.verifyCurrentSession)
case .renameCurrentSession:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.renameSession(currentSessionInfo))
case .logoutOfCurrentSession:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.logoutOfSession(currentSessionInfo))
case .viewCurrentSessionDetails:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
@@ -62,8 +67,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
case .viewAllInactiveSessions:
showSessions(filteredBy: .inactive)
case .viewAllOtherSessions:
// TODO: showSessions(filteredBy: .all)
break
showSessions(filteredBy: .all)
case .tapUserSession(let sessionId):
guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else {
assertionFailure("Missing session info")
@@ -92,19 +96,15 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
state.showLoadingIndicator = true
userSessionsOverviewService.updateOverviewData { [weak self] result in
guard let self = self else {
return
}
guard let self = self else { return }
self.state.showLoadingIndicator = false
switch result {
case .success(let overViewData):
self.updateViewState(with: overViewData)
case .failure(let error):
if case let .failure(error) = result {
// TODO:
break
}
// No need to consume .success as there's a subscription on the data.
}
}
@@ -73,7 +73,7 @@ struct UserSessionListPreview: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in
ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo)
UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in
@@ -22,7 +22,7 @@ struct UserSessionListItemViewDataFactory {
sessionDisplayName: sessionInfo.name)
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
isVerified: sessionInfo.isVerified)
verificationState: sessionInfo.verificationState)
return UserSessionListItemViewData(sessionId: sessionInfo.id,
sessionName: sessionName,
sessionDetails: sessionDetails,
@@ -50,7 +50,15 @@ struct UserSessionListItemViewDataFactory {
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
let sessionDetailsString: String
let sessionStatusText = sessionInfo.isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
let sessionStatusText: String
switch sessionInfo.verificationState {
case .verified:
sessionStatusText = VectorL10n.userSessionVerifiedShort
case .unverified:
sessionStatusText = VectorL10n.userSessionUnverifiedShort
case .unknown:
sessionStatusText = VectorL10n.userSessionVerificationUnknownShort
}
var lastActivityDateString: String?
@@ -0,0 +1,65 @@
//
// 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 UserSessionsListViewAllView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let count: Int
var onBackgroundTap: (() -> Void)?
var body: some View {
Button {
onBackgroundTap?()
} label: {
Button(action: { onBackgroundTap?() }) {
VStack(spacing: 0) {
HStack {
Text("View all (\(count))")
.font(theme.fonts.body)
.foregroundColor(theme.colors.accent)
.frame(maxWidth: .infinity, alignment: .leading)
Image(Asset.Images.chevron.name)
}
.padding(.vertical, 15)
.padding(.trailing, 20)
SeparatorLine()
}
.background(theme.colors.background)
.padding(.leading, 72)
}
}
.accessibilityIdentifier("ViewAllButton")
}
}
struct UserSessionsListViewAllView_Previews: PreviewProvider {
static var previews: some View {
Group {
UserSessionsListViewAllView(count: 8)
.previewLayout(PreviewLayout.sizeThatFits)
.theme(.light)
.preferredColorScheme(.light)
UserSessionsListViewAllView(count: 8)
.previewLayout(PreviewLayout.sizeThatFits)
.theme(.dark)
.preferredColorScheme(.dark)
}
}
}
@@ -21,6 +21,8 @@ struct UserSessionsOverview: View {
@ObservedObject var viewModel: UserSessionsOverviewViewModel.Context
private let maxOtherSessionsToDisplay = 5
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
@@ -121,8 +123,10 @@ struct UserSessionsOverview: View {
private var currentSessionMenu: some View {
Menu {
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
SwiftUI.Section {
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
}
}
if #available(iOS 15, *) {
@@ -135,18 +139,27 @@ struct UserSessionsOverview: View {
}
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis")
.foregroundColor(theme.colors.secondaryContent)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
.offset(x: 8) // Re-align the symbol after applying padding.
}
private var otherSessionsSection: some View {
SwiftUI.Section {
LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.otherSessionsViewData) { viewData in
ForEach(viewModel.viewState.otherSessionsViewData.prefix(maxOtherSessionsToDisplay)) { viewData in
UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
viewModel.send(viewAction: .tapUserSession(sessionId))
})
}
if viewModel.viewState.otherSessionsViewData.count > maxOtherSessionsToDisplay {
UserSessionsListViewAllView(count: viewModel.viewState.otherSessionsViewData.count) {
viewModel.send(viewAction: .viewAllOtherSessions)
}
}
}
.background(theme.colors.background)
} header: {