diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/Contents.json
new file mode 100644
index 000000000..81ee52eeb
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "user_other_sessions_filter.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/user_other_sessions_filter.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/user_other_sessions_filter.svg
new file mode 100644
index 000000000..a2b8549a1
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter.imageset/user_other_sessions_filter.svg
@@ -0,0 +1,3 @@
+
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/Contents.json
new file mode 100644
index 000000000..89113e4ef
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "user_other_sessions_filter_selected.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/user_other_sessions_filter_selected.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/user_other_sessions_filter_selected.svg
new file mode 100644
index 000000000..f964fdd1c
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_filter_selected.imageset/user_other_sessions_filter_selected.svg
@@ -0,0 +1,6 @@
+
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/Contents.json
new file mode 100644
index 000000000..fd25f3b8e
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "user_other_sessions_verified.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/user_other_sessions_verified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/user_other_sessions_verified.svg
new file mode 100644
index 000000000..793d65784
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_verified.imageset/user_other_sessions_verified.svg
@@ -0,0 +1,4 @@
+
diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings
index 650b10126..45b00489a 100644
--- a/Riot/Assets/en.lproj/Vector.strings
+++ b/Riot/Assets/en.lproj/Vector.strings
@@ -2437,7 +2437,18 @@ To enable access, tap Settings> Location and select Always";
"user_other_session_security_recommendation_title" = "Security recommendation";
"user_other_session_unverified_sessions_header_subtitle" = "Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore.";
"user_other_session_unverified_current_session_details" = "%@ · Your current session";
+"user_other_session_verified_sessions_header_subtitle" = "For best security, sign out from any session that you don’t recognize or use anymore.";
+"user_other_session_filter" = "Filter";
+"user_other_session_filter_menu_all" = "All sessions";
+"user_other_session_filter_menu_verified" = "Verified";
+"user_other_session_filter_menu_unverified" = "Unverified";
+"user_other_session_filter_menu_inactive" = "Inactive";
+
+"user_other_session_no_inactive_sessions" = "No inactive sessions found.";
+"user_other_session_no_verified_sessions" = "No verified sessions found.";
+"user_other_session_no_unverified_sessions" = "No unverified sessions found.";
+"user_other_session_clear_filter" = "Clear filter";
// First item is client name and second item is session display name
"user_session_name" = "%@: %@";
diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift
index 9ab2c8c42..fbfbbe9a8 100644
--- a/Riot/Generated/Images.swift
+++ b/Riot/Generated/Images.swift
@@ -107,8 +107,11 @@ internal class Asset: NSObject {
internal static let deviceTypeMobile = ImageAsset(name: "device_type_mobile")
internal static let deviceTypeUnknown = ImageAsset(name: "device_type_unknown")
internal static let deviceTypeWeb = ImageAsset(name: "device_type_web")
+ internal static let userOtherSessionsFilter = ImageAsset(name: "user_other_sessions_filter")
+ internal static let userOtherSessionsFilterSelected = ImageAsset(name: "user_other_sessions_filter_selected")
internal static let userOtherSessionsInactive = ImageAsset(name: "user_other_sessions_inactive")
internal static let userOtherSessionsUnverified = ImageAsset(name: "user_other_sessions_unverified")
+ internal static let userOtherSessionsVerified = ImageAsset(name: "user_other_sessions_verified")
internal static let userSessionListItemInactiveSession = ImageAsset(name: "user_session_list_item_inactive_session")
internal static let userSessionUnverified = ImageAsset(name: "user_session_unverified")
internal static let userSessionVerificationUnknown = ImageAsset(name: "user_session_verification_unknown")
diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift
index 7a01f8b8f..6d4281200 100644
--- a/Riot/Generated/Strings.swift
+++ b/Riot/Generated/Strings.swift
@@ -8635,6 +8635,42 @@ public class VectorL10n: NSObject {
public static func userInactiveSessionItemWithDate(_ p1: String) -> String {
return VectorL10n.tr("Vector", "user_inactive_session_item_with_date", p1)
}
+ /// Clear filter
+ public static var userOtherSessionClearFilter: String {
+ return VectorL10n.tr("Vector", "user_other_session_clear_filter")
+ }
+ /// Filter
+ public static var userOtherSessionFilter: String {
+ return VectorL10n.tr("Vector", "user_other_session_filter")
+ }
+ /// All sessions
+ public static var userOtherSessionFilterMenuAll: String {
+ return VectorL10n.tr("Vector", "user_other_session_filter_menu_all")
+ }
+ /// Inactive
+ public static var userOtherSessionFilterMenuInactive: String {
+ return VectorL10n.tr("Vector", "user_other_session_filter_menu_inactive")
+ }
+ /// Unverified
+ public static var userOtherSessionFilterMenuUnverified: String {
+ return VectorL10n.tr("Vector", "user_other_session_filter_menu_unverified")
+ }
+ /// Verified
+ public static var userOtherSessionFilterMenuVerified: String {
+ return VectorL10n.tr("Vector", "user_other_session_filter_menu_verified")
+ }
+ /// No inactive sessions found.
+ public static var userOtherSessionNoInactiveSessions: String {
+ return VectorL10n.tr("Vector", "user_other_session_no_inactive_sessions")
+ }
+ /// No unverified sessions found.
+ public static var userOtherSessionNoUnverifiedSessions: String {
+ return VectorL10n.tr("Vector", "user_other_session_no_unverified_sessions")
+ }
+ /// No verified sessions found.
+ public static var userOtherSessionNoVerifiedSessions: String {
+ return VectorL10n.tr("Vector", "user_other_session_no_verified_sessions")
+ }
/// Security recommendation
public static var userOtherSessionSecurityRecommendationTitle: String {
return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title")
@@ -8647,6 +8683,10 @@ public class VectorL10n: NSObject {
public static var userOtherSessionUnverifiedSessionsHeaderSubtitle: String {
return VectorL10n.tr("Vector", "user_other_session_unverified_sessions_header_subtitle")
}
+ /// For best security, sign out from any session that you don’t recognize or use anymore.
+ public static var userOtherSessionVerifiedSessionsHeaderSubtitle: String {
+ return VectorL10n.tr("Vector", "user_other_session_verified_sessions_header_subtitle")
+ }
/// Name
public static var userSessionDetailsApplicationName: String {
return VectorL10n.tr("Vector", "user_session_details_application_name")
diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift
index 965519107..ac03303bd 100644
--- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift
+++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift
@@ -141,7 +141,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
return UserSessionOverviewCoordinator(parameters: parameters)
}
- private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
+ private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: UserOtherSessionsFilter) {
let title = filter == .all ? VectorL10n.userSessionsOverviewOtherSessionsSectionTitle : VectorL10n.userOtherSessionSecurityRecommendationTitle
let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos,
filterBy: filter,
@@ -157,7 +157,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
}
private func createOtherSessionsCoordinator(sessionInfos: [UserSessionInfo],
- filterBy filter: OtherUserSessionsFilter,
+ filterBy filter: UserOtherSessionsFilter,
title: String) -> UserOtherSessionsCoordinator {
let parameters = UserOtherSessionsCoordinatorParameters(sessionInfos: sessionInfos,
filter: filter,
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift
index 607a87aa9..82dcd78d7 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift
@@ -19,7 +19,7 @@ import SwiftUI
struct UserOtherSessionsCoordinatorParameters {
let sessionInfos: [UserSessionInfo]
- let filter: OtherUserSessionsFilter
+ let filter: UserOtherSessionsFilter
let title: String
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift
index c3a23a79b..2fb7d8910 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift
@@ -27,6 +27,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
case all
case inactiveSessions
case unverifiedSessions
+ case verifiedSessions
/// The associated screen
var screenType: Any.Type {
@@ -36,7 +37,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
/// A list of screen state definitions
static var allCases: [MockUserOtherSessionsScreenState] {
// Each of the presence statuses
- [.all, .inactiveSessions, .unverifiedSessions]
+ [.all, .inactiveSessions, .unverifiedSessions, .verifiedSessions]
}
/// Generate the view struct for the screen state.
@@ -55,6 +56,10 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
viewModel = UserOtherSessionsViewModel(sessionInfos: unverifiedSessions(),
filter: .unverified,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
+ case .verifiedSessions:
+ viewModel = UserOtherSessionsViewModel(sessionInfos: verifiedSessions(),
+ filter: .verified,
+ title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
}
// can simulate service and viewModel actions here if needs be.
@@ -167,6 +172,41 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isCurrent: false)]
}
+ private func verifiedSessions() -> [UserSessionInfo] {
+ [UserSessionInfo(id: "0",
+ name: "iOS",
+ deviceType: .mobile,
+ verificationState: .verified,
+ lastSeenIP: "10.0.0.10",
+ lastSeenTimestamp: nil,
+ applicationName: nil,
+ applicationVersion: nil,
+ applicationURL: nil,
+ deviceModel: nil,
+ deviceOS: nil,
+ lastSeenIPLocation: nil,
+ clientName: nil,
+ clientVersion: nil,
+ isActive: true,
+ isCurrent: true),
+ 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: true,
+ isCurrent: false)]
+ }
+
private func allSessions() -> [UserSessionInfo] {
[UserSessionInfo(id: "0",
name: "iOS",
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift
index 34f54b604..1273f32d5 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift
@@ -21,7 +21,7 @@ class UserOtherSessionsUITests: MockScreenTestCase {
func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() {
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title)
- XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle].exists)
+ XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionFilterMenuInactive].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists)
}
@@ -34,7 +34,7 @@ class UserOtherSessionsUITests: MockScreenTestCase {
func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctHeaderDisplayed() {
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title)
- XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle].exists)
+ XCTAssertTrue(app.staticTexts[VectorL10n.userSessionUnverifiedShort].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists)
}
@@ -49,4 +49,11 @@ class UserOtherSessionsUITests: MockScreenTestCase {
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo].exists)
}
+
+ func test_whenOtherSessionsWithVerifiedSessionFilterPresented_correctHeaderDisplayed() {
+ app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.verifiedSessions.title)
+
+ XCTAssertTrue(app.staticTexts[VectorL10n.userSessionVerifiedShort].exists)
+ XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle].exists)
+ }
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
index f48e3c52a..05a25b5f5 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
@@ -19,12 +19,27 @@ import XCTest
@testable import RiotSwiftUI
class UserOtherSessionsViewModelTests: XCTestCase {
+ private let unverifiedSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionUnverifiedShort,
+ subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle,
+ iconName: Asset.Images.userOtherSessionsUnverified.name)
+
+ private let inactiveSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuInactive,
+ subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
+ iconName: Asset.Images.userOtherSessionsInactive.name)
+
+ private let allSectionHeader = UserOtherSessionsHeaderViewData(title: nil,
+ subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
+ iconName: nil)
+
+ private let verifiedSectionHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuVerified,
+ subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle,
+ iconName: Asset.Images.userOtherSessionsVerified.name)
+
func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() {
let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2")
- let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"),
- expectedUserSessionInfo],
- filter: .inactive,
- title: "Title")
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ expectedUserSessionInfo]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive)
var modelResult: UserOtherSessionsViewModelResult?
sut.completion = { result in
@@ -35,40 +50,102 @@ class UserOtherSessionsViewModelTests: XCTestCase {
}
func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() {
- let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
- let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos,
- filter: .inactive,
- title: "Title")
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isActive: false),
+ createUserSessionInfo(sessionId: "session 2", isActive: false)]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive)
- let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
- subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
- iconName: Asset.Images.userOtherSessionsInactive.name)
let expectedItems = sessionInfos.filter { !$0.isActive }.asViewData()
- let expectedState = UserOtherSessionsViewState(title: "Title",
- sections: [.sessionItems(header: expectedHeader, items: expectedItems)])
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .inactive),
+ title: "Title",
+ sections: [.sessionItems(header: inactiveSectionHeader, items: expectedItems)])
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 sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .all)
- 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)])
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .all),
+ title: "Title",
+ sections: [.sessionItems(header: allSectionHeader, items: expectedItems)])
XCTAssertEqual(sut.state, expectedState)
}
- private func createUserSessionInfo(sessionId: String) -> UserSessionInfo {
+ func test_whenModelCreated_withUnverifiedFilter_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1"),
+ createUserSessionInfo(sessionId: "session 2")]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified)
+
+ let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .unverified),
+ title: "Title",
+ sections: [.sessionItems(header: unverifiedSectionHeader, items: expectedItems)])
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenModelCreated_withVerifiedFilter_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true),
+ createUserSessionInfo(sessionId: "session 2", isVerified: true)]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .verified)
+
+ let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .verified),
+ title: "Title",
+ sections: [.sessionItems(header: verifiedSectionHeader, items: expectedItems)])
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenModelCreated_withVerifiedFilterWithNoVerifiedSessions_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: false),
+ createUserSessionInfo(sessionId: "session 2", isVerified: false)]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .verified)
+
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .verified),
+ title: "Title",
+ sections: [.emptySessionItems(header: verifiedSectionHeader, title: VectorL10n.userOtherSessionNoVerifiedSessions)])
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenModelCreated_withUnverifiedFilterWithNoUnverifiedSessions_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isVerified: true),
+ createUserSessionInfo(sessionId: "session 2", isVerified: true)]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .unverified)
+
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .unverified),
+ title: "Title",
+ sections: [.emptySessionItems(header: unverifiedSectionHeader, title: VectorL10n.userOtherSessionNoUnverifiedSessions)])
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ func test_whenModelCreated_withInactiveFilterWithNoInactiveSessions_viewStateIsCorrect() {
+ let sessionInfos = [createUserSessionInfo(sessionId: "session 1", isActive: true),
+ createUserSessionInfo(sessionId: "session 2", isActive: true)]
+ let sut = createSUT(sessionInfos: sessionInfos, filter: .inactive)
+
+ let expectedState = UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: .inactive),
+ title: "Title",
+ sections: [.emptySessionItems(header: inactiveSectionHeader, title: VectorL10n.userOtherSessionNoInactiveSessions)])
+ XCTAssertEqual(sut.state, expectedState)
+ }
+
+ private func createSUT(sessionInfos: [UserSessionInfo],
+ filter: UserOtherSessionsFilter,
+ title: String = "Title") -> UserOtherSessionsViewModel {
+ UserOtherSessionsViewModel(sessionInfos: sessionInfos,
+ filter: filter,
+ title: title)
+ }
+
+ private func createUserSessionInfo(sessionId: String,
+ isVerified: Bool = false,
+ isActive: Bool = true,
+ isCurrent: Bool = false) -> UserSessionInfo {
UserSessionInfo(id: sessionId,
name: "iOS",
deviceType: .mobile,
- verificationState: .unverified,
+ verificationState: isVerified ? .verified : .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: nil,
@@ -79,7 +156,7 @@ class UserOtherSessionsViewModelTests: XCTestCase {
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
- isActive: true,
- isCurrent: true)
+ isActive: isActive,
+ isCurrent: isCurrent)
}
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsFilter.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsFilter.swift
new file mode 100644
index 000000000..9450c4d74
--- /dev/null
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsFilter.swift
@@ -0,0 +1,40 @@
+//
+// Copyright 2022 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+enum UserOtherSessionsFilter: Identifiable, Equatable, CaseIterable {
+ var id: Self { self }
+ case all
+ case verified
+ case unverified
+ case inactive
+}
+
+extension UserOtherSessionsFilter {
+ var menuLocalizedName: String {
+ switch self {
+ case .all:
+ return VectorL10n.userOtherSessionFilterMenuAll
+ case .verified:
+ return VectorL10n.userOtherSessionFilterMenuVerified
+ case .unverified:
+ return VectorL10n.userOtherSessionFilterMenuUnverified
+ case .inactive:
+ return VectorL10n.userOtherSessionFilterMenuInactive
+ }
+ }
+}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift
index 53679d990..b81444b92 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift
@@ -31,18 +31,26 @@ enum UserOtherSessionsViewModelResult: Equatable {
// MARK: View
struct UserOtherSessionsViewState: BindableState, Equatable {
+ var bindings: UserOtherSessionsBindings
let title: String
var sections: [UserOtherSessionsSection]
}
+struct UserOtherSessionsBindings: Equatable {
+ var filter: UserOtherSessionsFilter
+}
+
enum UserOtherSessionsSection: Hashable, Identifiable {
var id: Self {
self
}
case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData])
+ case emptySessionItems(header: UserOtherSessionsHeaderViewData, title: String)
}
enum UserOtherSessionsViewAction {
case userOtherSessionSelected(sessionId: String)
+ case filterWasChanged
+ case clearFilter
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
index 90b162b0a..9bad552d8 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
@@ -18,22 +18,18 @@ import SwiftUI
typealias UserOtherSessionsViewModelType = StateStoreViewModel
-enum OtherUserSessionsFilter {
- case all
- case inactive
- case unverified
-}
-
class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol {
var completion: ((UserOtherSessionsViewModelResult) -> Void)?
private let sessionInfos: [UserSessionInfo]
init(sessionInfos: [UserSessionInfo],
- filter: OtherUserSessionsFilter,
+ filter: UserOtherSessionsFilter,
title: String) {
self.sessionInfos = sessionInfos
- super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: []))
- updateViewState(sessionInfos: sessionInfos, filter: filter)
+ super.init(initialViewState: UserOtherSessionsViewState(bindings: UserOtherSessionsBindings(filter: filter),
+ title: title,
+ sections: []))
+ updateViewState()
}
// MARK: - Public
@@ -46,18 +42,29 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
return
}
completion?(.showUserSessionOverview(sessionInfo: session))
+ case .filterWasChanged:
+ updateViewState()
+ case .clearFilter:
+ state.bindings.filter = .all
+ updateViewState()
}
}
// MARK: - Private
- private func updateViewState(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) {
- let sectionItems = createSectionItems(sessionInfos: sessionInfos, filter: filter)
- let sectionHeader = createHeaderData(filter: filter)
- state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)]
+ private func updateViewState() {
+ let sectionItems = createSectionItems(sessionInfos: sessionInfos, filter: state.bindings.filter)
+ let sectionHeader = createHeaderData(filter: state.bindings.filter)
+ if sectionItems.isEmpty {
+ state.sections = [.emptySessionItems(header: sectionHeader,
+ title: noSessionsTitle(filter: state.bindings.filter))]
+ } else {
+ state.sections = [.sessionItems(header: sectionHeader,
+ items: sectionItems)]
+ }
}
- private func createSectionItems(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) -> [UserSessionListItemViewData] {
+ private func createSectionItems(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter) -> [UserSessionListItemViewData] {
filterSessions(sessionInfos: sessionInfos, by: filter)
.map {
UserSessionListItemViewDataFactory().create(from: $0,
@@ -65,7 +72,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
}
}
- private func filterSessions(sessionInfos: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
+ private func filterSessions(sessionInfos: [UserSessionInfo], by filter: UserOtherSessionsFilter) -> [UserSessionInfo] {
switch filter {
case .all:
return sessionInfos.filter { !$0.isCurrent }
@@ -73,23 +80,43 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
return sessionInfos.filter { !$0.isActive }
case .unverified:
return sessionInfos.filter { $0.verificationState != .verified }
+ case .verified:
+ return sessionInfos.filter { $0.verificationState == .verified }
}
}
- private func createHeaderData(filter: OtherUserSessionsFilter) -> UserOtherSessionsHeaderViewData {
+ private func createHeaderData(filter: UserOtherSessionsFilter) -> UserOtherSessionsHeaderViewData {
switch filter {
case .all:
return UserOtherSessionsHeaderViewData(title: nil,
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
iconName: nil)
case .inactive:
- return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
+ return UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuInactive,
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
iconName: Asset.Images.userOtherSessionsInactive.name)
case .unverified:
- return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle,
+ return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionUnverifiedShort,
subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle,
iconName: Asset.Images.userOtherSessionsUnverified.name)
+ case .verified:
+ return UserOtherSessionsHeaderViewData(title: VectorL10n.userOtherSessionFilterMenuVerified,
+ subtitle: VectorL10n.userOtherSessionVerifiedSessionsHeaderSubtitle,
+ iconName: Asset.Images.userOtherSessionsVerified.name)
+ }
+ }
+
+ private func noSessionsTitle(filter: UserOtherSessionsFilter) -> String {
+ switch filter {
+ case .all:
+ assertionFailure("The view is not intended to be displayed without any session")
+ return ""
+ case .verified:
+ return VectorL10n.userOtherSessionNoVerifiedSessions
+ case .unverified:
+ return VectorL10n.userOtherSessionNoUnverifiedSessions
+ case .inactive:
+ return VectorL10n.userOtherSessionNoInactiveSessions
}
}
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift
index 6bcc7d034..9b64b201b 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift
@@ -27,12 +27,32 @@ struct UserOtherSessions: View {
switch section {
case let .sessionItems(header: header, items: items):
createSessionItemsSection(header: header, items: items)
+ case let .emptySessionItems(header: header, title: title):
+ createEmptySessionsItemsSection(header: header, title: title)
}
}
}
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.navigationTitle(viewModel.viewState.title)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Menu {
+ Picker("", selection: $viewModel.filter) {
+ ForEach(UserOtherSessionsFilter.allCases) { filter in
+ Text(filter.menuLocalizedName).tag(filter)
+ }
+ }
+ .labelsHidden()
+ .onChange(of: viewModel.filter) { _ in
+ viewModel.send(viewAction: .filterWasChanged)
+ }
+ } label: {
+ Image(viewModel.filter == .all ? Asset.Images.userOtherSessionsFilter.name : Asset.Images.userOtherSessionsFilterSelected.name)
+ }
+ .accessibilityLabel(VectorL10n.userOtherSessionFilter)
+ }
+ }
}
private func createSessionItemsSection(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData]) -> some View {
@@ -46,11 +66,43 @@ struct UserOtherSessions: View {
}
.background(theme.colors.background)
} header: {
- UserOtherSessionsHeaderView(viewData: header)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.top, 24.0)
+ headerView(header: header)
}
}
+
+ private func createEmptySessionsItemsSection(header: UserOtherSessionsHeaderViewData, title: String) -> some View {
+ SwiftUI.Section {
+ VStack {
+ Text(title)
+ .font(theme.fonts.footnote)
+ .foregroundColor(theme.colors.primaryContent)
+ .padding(.bottom, 20)
+ Button {
+ viewModel.send(viewAction: .clearFilter)
+ } label: {
+ VStack(spacing: 0) {
+ SeparatorLine()
+ Text(VectorL10n.userOtherSessionClearFilter)
+ .font(theme.fonts.body)
+ .foregroundColor(theme.colors.accent)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.vertical, 11)
+ SeparatorLine()
+ }
+ .background(theme.colors.background)
+ }
+ }
+
+ } header: {
+ headerView(header: header)
+ }
+ }
+
+ private func headerView(header: UserOtherSessionsHeaderViewData) -> some View {
+ UserOtherSessionsHeaderView(viewData: header)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 24.0)
+ }
}
// MARK: - Previews
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift
index 32f5bb090..790d3c5dc 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift
@@ -94,7 +94,7 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
loadingIndicator = nil
}
- private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
+ private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: UserOtherSessionsFilter) {
completion?(.openOtherSessions(sessionInfos: sessionInfos, filter: filter))
}
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift
index b8b57ebf4..0abbb93cd 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift
@@ -23,14 +23,14 @@ enum UserSessionsOverviewCoordinatorResult {
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case openSessionOverview(sessionInfo: UserSessionInfo)
- case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
+ case openOtherSessions(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter)
case linkDevice
}
// MARK: View model
enum UserSessionsOverviewViewModelResult: Equatable {
- case showOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
+ case showOtherSessions(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter)
case verifyCurrentSession
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift
index 03691265d..7aeae122b 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift
@@ -108,7 +108,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
}
}
- private func showSessions(filteredBy filter: OtherUserSessionsFilter) {
+ private func showSessions(filteredBy filter: UserOtherSessionsFilter) {
completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos,
filter: filter))
}
diff --git a/changelog.d/6838.wip b/changelog.d/6838.wip
new file mode 100644
index 000000000..15c28e09a
--- /dev/null
+++ b/changelog.d/6838.wip
@@ -0,0 +1 @@
+Device Manager: Filter sessions.