diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_inactive.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_inactive.imageset/Contents.json new file mode 100644 index 000000000..7261e4ae4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_inactive.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_sessions_inactive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_inactive.imageset/user_sessions_inactive.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_inactive.imageset/user_sessions_inactive.svg new file mode 100644 index 000000000..d7baa4786 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_inactive.imageset/user_sessions_inactive.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_unverified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_unverified.imageset/Contents.json new file mode 100644 index 000000000..2c0d97214 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_unverified.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_sessions_unverified.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_unverified.imageset/user_sessions_unverified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_unverified.imageset/user_sessions_unverified.svg new file mode 100644 index 000000000..4db2abe67 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_sessions_unverified.imageset/user_sessions_unverified.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 7165bdcd2..79cf31ba0 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2358,10 +2358,21 @@ To enable access, tap Settings> Location and select Always"; "user_sessions_overview_title" = "Sessions"; -"user_sessions_overview_other_sessions_section_title" = "OTHER SESSIONS"; +"user_sessions_overview_security_recommendations_section_title" = "Security recommendations"; +"user_sessions_overview_security_recommendations_section_info" = "Improve your account security by following these recommendations."; + +"user_sessions_overview_security_recommendations_unverified_title" = "Unverified sessions"; +"user_sessions_overview_security_recommendations_unverified_info" = "Verify or sign out from unverified sessions."; + +"user_sessions_overview_security_recommendations_inactive_title" = "Inactive sessions"; +"user_sessions_overview_security_recommendations_inactive_info" = "Consider signing out from old sessions (90 days or older) you don’t use anymore."; + +"user_sessions_overview_other_sessions_section_title" = "Other sessions"; "user_sessions_overview_other_sessions_section_info" = "For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore."; -"user_sessions_overview_current_session_section_title" = "CURRENT SESSION"; +"user_sessions_overview_current_session_section_title" = "Current session"; + +"user_sessions_view_all_action" = "View all (%d)"; "user_session_verified" = "Verified session"; "user_session_unverified" = "Unverified session"; @@ -2374,6 +2385,7 @@ To enable access, tap Settings> Location and select Always"; "user_session_verified_additional_info" = "Your current session is ready for secure messaging."; "user_session_unverified_additional_info" = "Verify your current session for enhanced secure messaging."; + // First item is client name and second item is session display name "user_session_name" = "%@: %@"; @@ -2385,8 +2397,8 @@ To enable access, tap Settings> Location and select Always"; "device_name_unknown" = "Unknown client"; "user_session_details_title" = "Session details"; -"user_session_details_session_section_header" = "SESSION"; -"user_session_details_device_section_header" = "DEVICE"; +"user_session_details_session_section_header" = "Session"; +"user_session_details_device_section_header" = "Device"; "user_session_details_session_name" = "Session name"; "user_session_details_session_id" = "Session ID"; "user_session_details_session_section_footer" = "Copy any data by tapping on it and holding it down."; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 6fc13773b..9dc652238 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -107,6 +107,8 @@ internal class Asset: NSObject { internal static let deviceTypeWeb = ImageAsset(name: "device_type_web") internal static let userSessionUnverified = ImageAsset(name: "user_session_unverified") internal static let userSessionVerified = ImageAsset(name: "user_session_verified") + internal static let userSessionsInactive = ImageAsset(name: "user_sessions_inactive") + internal static let userSessionsUnverified = ImageAsset(name: "user_sessions_unverified") internal static let e2eBlocked = ImageAsset(name: "e2e_blocked") internal static let e2eUnencrypted = ImageAsset(name: "e2e_unencrypted") internal static let e2eWarning = ImageAsset(name: "e2e_warning") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 00249e843..31d85976d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8471,7 +8471,7 @@ public class VectorL10n: NSObject { public static var userSessionDetailsDeviceIpAddress: String { return VectorL10n.tr("Vector", "user_session_details_device_ip_address") } - /// DEVICE + /// Device public static var userSessionDetailsDeviceSectionHeader: String { return VectorL10n.tr("Vector", "user_session_details_device_section_header") } @@ -8487,7 +8487,7 @@ public class VectorL10n: NSObject { public static var userSessionDetailsSessionSectionFooter: String { return VectorL10n.tr("Vector", "user_session_details_session_section_footer") } - /// SESSION + /// Session public static var userSessionDetailsSessionSectionHeader: String { return VectorL10n.tr("Vector", "user_session_details_session_section_header") } @@ -8551,7 +8551,7 @@ public class VectorL10n: NSObject { public static var userSessionViewDetails: String { return VectorL10n.tr("Vector", "user_session_view_details") } - /// CURRENT SESSION + /// Current session public static var userSessionsOverviewCurrentSessionSectionTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_current_session_section_title") } @@ -8559,10 +8559,34 @@ public class VectorL10n: NSObject { public static var userSessionsOverviewOtherSessionsSectionInfo: String { return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info") } - /// OTHER SESSIONS + /// Other sessions public static var userSessionsOverviewOtherSessionsSectionTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_title") } + /// Consider signing out from old sessions (90 days or older) you don’t use anymore. + public static var userSessionsOverviewSecurityRecommendationsInactiveInfo: String { + return VectorL10n.tr("Vector", "user_sessions_overview_security_recommendations_inactive_info") + } + /// Inactive sessions + public static var userSessionsOverviewSecurityRecommendationsInactiveTitle: String { + return VectorL10n.tr("Vector", "user_sessions_overview_security_recommendations_inactive_title") + } + /// Improve your account security by following these recommendations. + public static var userSessionsOverviewSecurityRecommendationsSectionInfo: String { + return VectorL10n.tr("Vector", "user_sessions_overview_security_recommendations_section_info") + } + /// Security recommendations + public static var userSessionsOverviewSecurityRecommendationsSectionTitle: String { + return VectorL10n.tr("Vector", "user_sessions_overview_security_recommendations_section_title") + } + /// Verify or sign out from unverified sessions. + public static var userSessionsOverviewSecurityRecommendationsUnverifiedInfo: String { + return VectorL10n.tr("Vector", "user_sessions_overview_security_recommendations_unverified_info") + } + /// Unverified sessions + public static var userSessionsOverviewSecurityRecommendationsUnverifiedTitle: String { + return VectorL10n.tr("Vector", "user_sessions_overview_security_recommendations_unverified_title") + } /// Sessions public static var userSessionsOverviewTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_title") @@ -8571,6 +8595,10 @@ public class VectorL10n: NSObject { public static var userSessionsSettings: String { return VectorL10n.tr("Vector", "user_sessions_settings") } + /// View all (%d) + public static func userSessionsViewAllAction(_ p1: Int) -> String { + return VectorL10n.tr("Vector", "user_sessions_view_all_action", p1) + } /// If you didn’t sign in to this session, your account may be compromised. public static var userVerificationSessionDetailsAdditionalInformationUntrustedCurrentUser: String { return VectorL10n.tr("Vector", "user_verification_session_details_additional_information_untrusted_current_user") diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift index a63491e5a..6fba28616 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift @@ -18,20 +18,11 @@ import Foundation /// Represents a user session information struct UserSessionInfo: Identifiable { - /// Delay after which session is considered inactive, 90 days - static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400 - - // MARK: - Properties - - var id: String { - sessionId - } - /// The session identifier - let sessionId: String + let id: String /// The session display name - let sessionName: String? + let name: String? /// The device type used by the session let deviceType: DeviceType @@ -46,35 +37,10 @@ struct UserSessionInfo: Identifiable { let lastSeenTimestamp: TimeInterval? /// True to indicate that session has been used under `inactiveSessionDurationTreshold` value - let isSessionActive: Bool + let isActive: Bool /// True to indicate that this is current user session - let isCurrentSession: Bool - - // MARK: - Setup - - init(sessionId: String, - sessionName: String?, - deviceType: DeviceType, - isVerified: Bool, - lastSeenIP: String?, - lastSeenTimestamp: TimeInterval?, - isCurrentSession: Bool) { - self.sessionId = sessionId - self.sessionName = sessionName - self.deviceType = deviceType - self.isVerified = isVerified - self.lastSeenIP = lastSeenIP - self.lastSeenTimestamp = lastSeenTimestamp - - if let lastSeenTimestamp = lastSeenTimestamp { - let elapsedTime = Date().timeIntervalSince1970 - lastSeenTimestamp - isSessionActive = elapsedTime < Self.inactiveSessionDurationTreshold - } else { - isSessionActive = true - } - self.isCurrentSession = isCurrentSession - } + let isCurrent: Bool } extension UserSessionInfo: Equatable { diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index a54915e23..bf49cedb8 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -136,13 +136,14 @@ struct UserSessionCardViewPreview: View { let viewData: UserSessionCardViewData init(isCurrentSessionInfo: Bool = false) { - let session = UserSessionInfo(sessionId: "alice", - sessionName: "iOS", + let session = UserSessionInfo(id: "alice", + name: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, - isCurrentSession: isCurrentSessionInfo) + isActive: true, + isCurrent: isCurrentSessionInfo) viewData = UserSessionCardViewData(session: session) } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift index 3fc86306e..339de5806 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift @@ -67,12 +67,12 @@ struct UserSessionCardViewData { extension UserSessionCardViewData { init(session: UserSessionInfo) { - self.init(sessionId: session.sessionId, - sessionDisplayName: session.sessionName, + self.init(sessionId: session.id, + sessionDisplayName: session.name, deviceType: session.deviceType, isVerified: session.isVerified, lastActivityTimestamp: session.lastSeenTimestamp, lastSeenIP: session.lastSeenIP, - isCurrentSessionDisplayMode: session.isCurrentSession) + isCurrentSessionDisplayMode: session.isCurrent) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index dcb31faef..22b263543 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -41,21 +41,23 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { let session: UserSessionInfo switch self { case .allSections: - session = UserSessionInfo(sessionId: "session", - sessionName: "iOS", + session = UserSessionInfo(id: "session", + name: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, - isCurrentSession: true) + isActive: true, + isCurrent: true) case .sessionSectionOnly: - session = UserSessionInfo(sessionId: "session", - sessionName: "iOS", + session = UserSessionInfo(id: "session", + name: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: nil, lastSeenTimestamp: Date().timeIntervalSince1970 - 100, - isCurrentSession: false) + isActive: true, + isCurrent: false) } let viewModel = UserSessionDetailsViewModel(session: session) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift index 20debe96b..cf4ddf43c 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift @@ -28,7 +28,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { sessionItems.append(sessionIdItem(sessionId: userSessionInfo.sessionId)) var sections = [UserSessionDetailsSectionViewData]() - sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), footer: VectorL10n.userSessionDetailsSessionSectionFooter, items: sessionItems)) let expectedModel = UserSessionDetailsViewState(sections: sections) @@ -47,7 +47,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { sessionItems.append(sessionIdItem(sessionId: userSessionInfo.sessionId)) var sections = [UserSessionDetailsSectionViewData]() - sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), footer: VectorL10n.userSessionDetailsSessionSectionFooter, items: sessionItems)) @@ -67,14 +67,14 @@ class UserSessionDetailsViewModelTests: XCTestCase { sessionItems.append(sessionIdItem(sessionId: userSessionInfo.sessionId)) var sections = [UserSessionDetailsSectionViewData]() - sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), footer: VectorL10n.userSessionDetailsSessionSectionFooter, items: sessionItems)) var deviceSectionItems = [UserSessionDetailsSectionItemViewData]() deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsDeviceIpAddress, value: "0.0.0.0")) - sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader, + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(), footer: nil, items: deviceSectionItems)) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift index 66a5d7559..9fe47469d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift @@ -45,15 +45,15 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD private func sessionSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData { var sessionItems = [UserSessionDetailsSectionItemViewData]() - if let sessionName = session.sessionName { + if let sessionName = session.name { sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsSessionName, value: sessionName)) } sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, - value: session.sessionId)) + value: session.id)) - return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), footer: VectorL10n.userSessionDetailsSessionSectionFooter, items: sessionItems) } @@ -65,7 +65,7 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD value: lastSeenIP)) } if deviceSectionItems.count > 0 { - return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader, + return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(), footer: nil, items: deviceSectionItems) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift index 4830859d0..0cfa0b7a4 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift @@ -41,22 +41,24 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { let viewModel: UserSessionOverviewViewModel switch self { case .currentSession: - let session = UserSessionInfo(sessionId: "session", - sessionName: "iOS", + let session = UserSessionInfo(id: "session", + name: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, - isCurrentSession: true) + isActive: true, + isCurrent: true) viewModel = UserSessionOverviewViewModel(session: session) case .otherSession: - let session = UserSessionInfo(sessionId: "session", - sessionName: "Mac", + let session = UserSessionInfo(id: "session", + name: "Mac", deviceType: .desktop, isVerified: true, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, - isCurrentSession: false) + isActive: true, + isCurrent: false) viewModel = UserSessionOverviewViewModel(session: session) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift index 95261d7c8..42eaaa6c7 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift @@ -27,7 +27,7 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio self.session = session let cardViewData = UserSessionCardViewData(session: session) - let state = UserSessionOverviewViewState(cardViewData: cardViewData, isCurrentSession: session.isCurrentSession) + let state = UserSessionOverviewViewState(cardViewData: cardViewData, isCurrentSession: session.isCurrent) super.init(initialViewState: state) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index da4e30ba8..da0127e83 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -18,6 +18,9 @@ import Foundation import MatrixSDK class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { + /// Delay after which session is considered inactive, 90 days + static private let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400 + private let mxSession: MXSession private(set) var overviewData: UserSessionsOverviewData @@ -52,7 +55,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { return overviewData.currentSession } - return overviewData.otherSessions.first(where: { $0.sessionId == sessionId }) + return overviewData.otherSessions.first(where: { $0.id == sessionId }) } // MARK: - Private @@ -85,7 +88,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { var otherSessions: [UserSessionInfo] = [] for session in allSessions { - if session.isCurrentSession { + if session.isCurrent { currentSession = session } else { otherSessions.append(session) @@ -94,7 +97,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { unverifiedSessions.append(session) } - if session.isSessionActive == false { + if session.isActive == false { inactiveSessions.append(session) } } @@ -114,13 +117,20 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { lastSeenTs = TimeInterval(device.lastSeenTs / 1000) } - return UserSessionInfo(sessionId: device.deviceId, - sessionName: device.displayName, + var isSessionActive = true + if let lastSeenTimestamp = lastSeenTs { + let elapsedTime = Date().timeIntervalSince1970 - lastSeenTimestamp + isSessionActive = elapsedTime < Self.inactiveSessionDurationTreshold + } + + return UserSessionInfo(id: device.deviceId, + name: device.displayName, deviceType: .unknown, isVerified: isSessionVerified, lastSeenIP: device.lastSeenIp, lastSeenTimestamp: lastSeenTs, - isCurrentSession: isCurrentSession) + isActive: isSessionActive, + isCurrent: isCurrentSession) } private func deviceInfo(for deviceId: String) -> MXDeviceInfo? { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index 13167d350..ac863f5ce 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -28,21 +28,49 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { } init() { - let currentSessionInfo = UserSessionInfo(sessionId: "alice", sessionName: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: nil, isCurrentSession: true) + let currentSession = UserSessionInfo(id: "alice", + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + isActive: true, + isCurrent: true) - let unverifiedSessionsInfo: [UserSessionInfo] = [] - - let inactiveSessionsInfo: [UserSessionInfo] = [] - - let otherSessionsInfo: [UserSessionInfo] = [ - UserSessionInfo(sessionId: "1", sessionName: "macOS", deviceType: .desktop, isVerified: true, lastSeenIP: "1.0.0.1", lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, isCurrentSession: false), - UserSessionInfo(sessionId: "2", sessionName: "Firefox on Windows", deviceType: .web, isVerified: true, lastSeenIP: "2.0.0.2", lastSeenTimestamp: Date().timeIntervalSince1970 - 100, isCurrentSession: false), - UserSessionInfo(sessionId: "3", sessionName: "Android", deviceType: .mobile, isVerified: false, lastSeenIP: "3.0.0.3", lastSeenTimestamp: Date().timeIntervalSince1970 - 10, isCurrentSession: false) + let otherSessions: [UserSessionInfo] = [ + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + isVerified: true, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "2", + name: "Firefox on Windows", + deviceType: .web, + isVerified: true, + lastSeenIP: "2.0.0.2", + lastSeenTimestamp: Date().timeIntervalSince1970 - 100, + isActive: true, + isCurrent: false), + UserSessionInfo(id: "3", + name: "Android", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10, + isActive: true, + isCurrent: false) ] - overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo, - unverifiedSessions: unverifiedSessionsInfo, - inactiveSessions: inactiveSessionsInfo, - otherSessions: otherSessionsInfo) + let unverifiedSessions: [UserSessionInfo] = otherSessions.filter { !$0.isVerified } + + let inactiveSessions: [UserSessionInfo] = otherSessions.filter { !$0.isActive } + + overviewData = UserSessionsOverviewData(currentSession: currentSession, + unverifiedSessions: unverifiedSessions, + inactiveSessions: inactiveSessions, + otherSessions: otherSessions) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift new file mode 100644 index 000000000..090051154 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift @@ -0,0 +1,111 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +struct SecurityRecommendationCard: View { + enum Style { + case unverified + case inactive + } + + @Environment(\.theme) var theme: ThemeSwiftUI + + let style: SecurityRecommendationCard.Style + let sessionCount: Int + let action: () -> Void + + var body: some View { + HStack(alignment: .top) { + Image(iconName) + VStack(alignment: .leading, spacing: 16.0) { + VStack(alignment: .leading, spacing: 8.0) { + Text(title) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.primaryContent) + + Text(subtitle) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + } + + Button { + action() + } label: { + Text(buttonTitle) + .font(theme.fonts.body) + } + .foregroundColor(theme.colors.accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .background(theme.colors.background) + .clipShape(backgroundShape) + .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape) + } + + private var backgroundShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 8) + } + + private var iconName: String { + switch style { + case .unverified: + return Asset.Images.userSessionsUnverified.name + case .inactive: + return Asset.Images.userSessionsInactive.name + } + } + + private var title: String { + switch style { + case .unverified: + return VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle + case .inactive: + return VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle + } + } + + private var subtitle: String { + switch style { + case .unverified: + return VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedInfo + case .inactive: + return VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo + } + } + + private var buttonTitle: String { + VectorL10n.userSessionsViewAllAction(sessionCount) + } +} + +struct SecurityRecommendationCard_Previews: PreviewProvider { + static var previews: some View { + body.theme(.light).preferredColorScheme(.light) + body.theme(.dark).preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: some View { + VStack { + SecurityRecommendationCard(style: .unverified, sessionCount: 4, action: { }) + SecurityRecommendationCard(style: .inactive, sessionCount: 100, action: { }) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift index b72301eb0..56bccbbad 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -69,6 +69,10 @@ struct UserSessionListItemViewData: Identifiable { extension UserSessionListItemViewData { init(session: UserSessionInfo) { - self.init(sessionId: session.sessionId, sessionDisplayName: session.sessionName, deviceType: session.deviceType, isVerified: session.isVerified, lastActivityDate: session.lastSeenTimestamp) + self.init(sessionId: session.id, + sessionDisplayName: session.name, + deviceType: session.deviceType, + isVerified: session.isVerified, + lastActivityDate: session.lastSeenTimestamp) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index 0d2c5cabe..925ab82b7 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -19,43 +19,14 @@ import SwiftUI struct UserSessionsOverview: View { @Environment(\.theme) private var theme: ThemeSwiftUI - @ViewBuilder - private var currentSessionsSection: some View { - if let currentSessionViewData = viewModel.viewState.currentSessionViewData { - SwiftUI.Section { - UserSessionCardView(viewData: currentSessionViewData, onVerifyAction: { _ in - viewModel.send(viewAction: .verifyCurrentSession) - }, onViewDetailsAction: { _ in - viewModel.send(viewAction: .viewCurrentSessionDetails) - }) - .padding(.horizontal, 16) - } header: { - Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle) - .font(theme.fonts.footnote) - .foregroundColor(theme.colors.secondaryContent) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.top, 24) - .padding(.bottom, 11) - } - } - } - - // MARK: Public - @ObservedObject var viewModel: UserSessionsOverviewViewModel.Context var body: some View { ScrollView { - // Security recommendations section - if viewModel.viewState.unverifiedSessionsViewData.isEmpty == false || viewModel.viewState.inactiveSessionsViewData.isEmpty == false { - // TODO: - } + securityRecommendationsSection - // Current session section currentSessionsSection - // Other sessions section if viewModel.viewState.otherSessionsViewData.isEmpty == false { otherSessionsSection } @@ -68,6 +39,69 @@ struct UserSessionsOverview: View { viewModel.send(viewAction: .viewAppeared) } } + + @ViewBuilder + private var securityRecommendationsSection: some View { + if hasSecurityRecommendations { + SwiftUI.Section { + if !viewModel.viewState.unverifiedSessionsViewData.isEmpty { + SecurityRecommendationCard(style: .unverified, + sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) { + viewModel.send(viewAction: .viewAllUnverifiedSessions) + } + } + + if !viewModel.viewState.inactiveSessionsViewData.isEmpty { + SecurityRecommendationCard(style: .inactive, + sessionCount: viewModel.viewState.inactiveSessionsViewData.count) { + viewModel.send(viewAction: .viewAllInactiveSessions) + } + } + } header: { + VStack(alignment: .leading) { + Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionTitle) + .textCase(.uppercase) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 8.0) + + Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionInfo) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 12.0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24) + } + .padding(.horizontal, 16) + } + } + + var hasSecurityRecommendations: Bool { + !viewModel.viewState.unverifiedSessionsViewData.isEmpty || !viewModel.viewState.inactiveSessionsViewData.isEmpty + } + + @ViewBuilder + private var currentSessionsSection: some View { + if let currentSessionViewData = viewModel.viewState.currentSessionViewData { + SwiftUI.Section { + UserSessionCardView(viewData: currentSessionViewData, onVerifyAction: { _ in + viewModel.send(viewAction: .verifyCurrentSession) + }, onViewDetailsAction: { _ in + viewModel.send(viewAction: .viewCurrentSessionDetails) + }) + } header: { + Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle) + .textCase(.uppercase) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 12.0) + .padding(.top, 24.0) + } + .padding(.horizontal, 16) + } + } private var otherSessionsSection: some View { SwiftUI.Section { @@ -83,17 +117,19 @@ struct UserSessionsOverview: View { } header: { VStack(alignment: .leading) { Text(VectorL10n.userSessionsOverviewOtherSessionsSectionTitle) + .textCase(.uppercase) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) - .padding(.bottom, 10) + .padding(.bottom, 8.0) Text(VectorL10n.userSessionsOverviewOtherSessionsSectionInfo) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) - .padding(.bottom, 11) + .padding(.bottom, 12.0) } - .padding(.horizontal, 16) - .padding(.top, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16.0) + .padding(.top, 24.0) } } }