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.