diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/Contents.json new file mode 100644 index 0000000000..f16dd6afe8 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_inactive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/user_other_sessions_inactive.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/user_other_sessions_inactive.svg new file mode 100644 index 0000000000..8603bbc319 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_inactive.imageset/user_other_sessions_inactive.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/Contents.json new file mode 100644 index 0000000000..e3af9f0531 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_session_list_item_inactive_session.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/user_session_list_item_inactive_session.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/user_session_list_item_inactive_session.svg new file mode 100644 index 0000000000..5aba5a38bb --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_list_item_inactive_session.imageset/user_session_list_item_inactive_session.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 571cf8959d..10f54cf0b3 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2388,10 +2388,14 @@ To enable access, tap Settings> Location and select Always"; "user_session_push_notifications" = "Push notifications"; "user_session_push_notifications_message" = "When turned on, this session will receive push notifications."; +"user_other_session_security_recommendation_title" = "Security recommendation"; + // First item is client name and second item is session display name "user_session_name" = "%@: %@"; "user_session_item_details" = "%@ ยท Last activity %@"; +"user_inactive_session_item" = "Inactive for 90+ days"; +"user_inactive_session_item_with_date" = "Inactive for 90+ days (%@)"; "device_name_desktop" = "%@ Desktop"; "device_name_web" = "%@ Web"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 006fd876cc..4908bfb7d7 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -105,6 +105,8 @@ 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 userOtherSessionsInactive = ImageAsset(name: "user_other_sessions_inactive") + internal static let userSessionListItemInactiveSession = ImageAsset(name: "user_session_list_item_inactive_session") 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") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index f8c0cc121c..02c55dac0e 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8483,6 +8483,18 @@ public class VectorL10n: NSObject { public static var userIdTitle: String { return VectorL10n.tr("Vector", "user_id_title") } + /// Inactive for 90+ days + public static var userInactiveSessionItem: String { + return VectorL10n.tr("Vector", "user_inactive_session_item") + } + /// Inactive for 90+ days (%@) + public static func userInactiveSessionItemWithDate(_ p1: String) -> String { + return VectorL10n.tr("Vector", "user_inactive_session_item_with_date", p1) + } + /// Security recommendation + public static var userOtherSessionSecurityRecommendationTitle: String { + return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title") + } /// Name public static var userSessionDetailsApplicationName: String { return VectorL10n.tr("Vector", "user_session_details_application_name") diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 05cc502348..3deb51ccbb 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -19,6 +19,7 @@ import Foundation /// The static list of mocked screens in RiotSwiftUI enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockUserOtherSessionsScreenState.self, MockUserSessionsOverviewScreenState.self, MockUserSessionDetailsScreenState.self, MockUserSessionOverviewScreenState.self, diff --git a/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift new file mode 100644 index 0000000000..f44ef9204e --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift @@ -0,0 +1,32 @@ +// +// 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 + +class InactiveUserSessionLastActivityFormatter { + private static var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + dateFormatter.dateStyle = .medium + dateFormatter.doesRelativeDateFormatting = true + return dateFormatter + }() + + static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: lastActivityTimestamp) + return InactiveUserSessionLastActivityFormatter.dateFormatter.string(from: date) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift index 397e945dc4..ee37fbcd83 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarViewData.swift @@ -18,7 +18,7 @@ import Foundation import SwiftUI /// View data for DeviceAvatarView -struct DeviceAvatarViewData { +struct DeviceAvatarViewData: Hashable { let deviceType: DeviceType let isVerified: Bool? } diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 83dc0361d2..5ad3cbbddd 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -55,6 +55,10 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { switch result { case let .openSessionOverview(sessionInfo: sessionInfo): self.openSessionOverview(sessionInfo: sessionInfo) + case let .openOtherSessions(sessionsInfo: sessionsInfo, filter: filter): + self.openOtherSessions(sessionsInfo: sessionsInfo, + filterBy: filter, + title: VectorL10n.userOtherSessionSecurityRecommendationTitle) } } return coordinator @@ -66,7 +70,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } private func createUserSessionDetailsCoordinator(sessionInfo: UserSessionInfo) -> UserSessionDetailsCoordinator { - let parameters = UserSessionDetailsCoordinatorParameters(session: sessionInfo) + let parameters = UserSessionDetailsCoordinatorParameters(sessionInfo: sessionInfo) return UserSessionDetailsCoordinator(parameters: parameters) } @@ -83,10 +87,34 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator { - let parameters = UserSessionOverviewCoordinatorParameters(session: self.parameters.session, sessionInfo: sessionInfo) + let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session, + sessionInfo: sessionInfo) return UserSessionOverviewCoordinator(parameters: parameters) } + private func openOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) { + let coordinator = createOtherSessionsCoordinator(sessionsInfo: sessionsInfo, + filterBy: filter, + title: title) + coordinator.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case let .openSessionDetails(sessionInfo: session): + self.openSessionDetails(sessionInfo: session) + } + } + pushScreen(with: coordinator) + } + + private func createOtherSessionsCoordinator(sessionsInfo: [UserSessionInfo], + filterBy filter: OtherUserSessionsFilter, + title: String) -> UserOtherSessionsCoordinator { + let parameters = UserOtherSessionsCoordinatorParameters(sessionsInfo: sessionsInfo, + filter: filter, + title: title) + return UserOtherSessionsCoordinator(parameters: parameters) + } + // MARK: - Public func start() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift new file mode 100644 index 0000000000..fd7fa89325 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -0,0 +1,70 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import CommonKit +import SwiftUI + +struct UserOtherSessionsCoordinatorParameters { + let sessionsInfo: [UserSessionInfo] + let filter: OtherUserSessionsFilter + let title: String +} + +final class UserOtherSessionsCoordinator: Coordinator, Presentable { + + private let parameters: UserOtherSessionsCoordinatorParameters + private let userOtherSessionsHostingController: UIViewController + private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserOtherSessionsCoordinatorResult) -> Void)? + + init(parameters: UserOtherSessionsCoordinatorParameters) { + self.parameters = parameters + + let viewModel = UserOtherSessionsViewModel(sessionsInfo: parameters.sessionsInfo, + filter: parameters.filter, + title: parameters.title) + let view = UserOtherSessions(viewModel: viewModel.context) + userOtherSessionsViewModel = viewModel + userOtherSessionsHostingController = VectorHostingController(rootView: view) + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userOtherSessionsHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[UserOtherSessionsCoordinator] did start.") + userOtherSessionsViewModel.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case let .showUserSessionOverview(sessionInfo: session): + self.completion?(.openSessionDetails(sessionInfo: session)) + } + MXLog.debug("[UserOtherSessionsCoordinator] UserOtherSessionsViewModel did complete with result: \(result).") + } + } + + func toPresentable() -> UIViewController { + userOtherSessionsHostingController + } + + // MARK: - Private +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift new file mode 100644 index 0000000000..788311fa99 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -0,0 +1,121 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + + case inactiveSessions + + /// The associated screen + var screenType: Any.Type { + UserOtherSessions.self + } + + /// A list of screen state definitions + static var allCases: [MockUserOtherSessionsScreenState] { + // Each of the presence statuses + [.inactiveSessions] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + + let viewModel = UserOtherSessionsViewModel(sessionsInfo: inactiveSessions(), + filter: .inactive, + title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(UserOtherSessions(viewModel: viewModel.context)) + ) + } + + private func inactiveSessions() -> [UserSessionInfo] { + [UserSessionInfo(id: "0", + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: true), + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + isVerified: true, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "2", + name: "Firefox on Windows", + deviceType: .web, + isVerified: true, + lastSeenIP: "2.0.0.2", + lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false), + UserSessionInfo(id: "3", + name: "Android", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: false, + isCurrent: false)] + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift new file mode 100644 index 0000000000..e5ae0f0c1c --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -0,0 +1,34 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import RiotSwiftUI +import XCTest + +class UserOtherSessionsUITests: MockScreenTestCase { + + func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) + + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) + } + + func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) + + XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Inactive for 90+ days"].exists) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift new file mode 100644 index 0000000000..43bf5c3580 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -0,0 +1,73 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import RiotSwiftUI + +class UserOtherSessionsViewModelTests: XCTestCase { + + + func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() { + let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2") + let sut = UserOtherSessionsViewModel(sessionsInfo: [createUserSessionInfo(sessionId: "session 1"), + expectedUserSessionInfo], + filter: .inactive, + title: "Title") + + var modelResult: UserOtherSessionsViewModelResult? + sut.completion = { result in + modelResult = result + } + sut.process(viewAction: .userOtherSessionSelected(sessionId: expectedUserSessionInfo.id)) + XCTAssertEqual(modelResult, .showUserSessionOverview(sessionInfo: expectedUserSessionInfo)) + } + + func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() { + let sessionsInfo = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")] + let sut = UserOtherSessionsViewModel(sessionsInfo: sessionsInfo, + filter: .inactive, + title: "Title") + + let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + let expectedItems = sessionsInfo.filter { !$0.isActive }.asViewData() + let expectedState = UserOtherSessionsViewState(title: "Title", + sections: [.sessionItems(header: expectedHeader, items: expectedItems)]) + XCTAssertEqual(sut.state, expectedState) + } + + + private func createUserSessionInfo(sessionId: String) -> UserSessionInfo { + UserSessionInfo(id: sessionId, + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: Date().timeIntervalSince1970 - 100, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: "iPhone XS", + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: true) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift new file mode 100644 index 0000000000..3cda39e336 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift @@ -0,0 +1,46 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: - Coordinator +enum UserOtherSessionsCoordinatorResult { + case openSessionDetails(sessionInfo: UserSessionInfo) +} + +// MARK: View model + +enum UserOtherSessionsViewModelResult: Equatable { + case showUserSessionOverview(sessionInfo: UserSessionInfo) +} + +// MARK: View + +struct UserOtherSessionsViewState: BindableState, Equatable { + let title: String + var sections: [UserOtherSessionsSection] +} + +enum UserOtherSessionsSection: Hashable, Identifiable { + var id: Self { + self + } + case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData]) +} + +enum UserOtherSessionsViewAction { + case userOtherSessionSelected(sessionId: String) +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift new file mode 100644 index 0000000000..a3c3997912 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -0,0 +1,91 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +typealias UserOtherSessionsViewModelType = StateStoreViewModel + +enum OtherUserSessionsFilter { + case all + case inactive + case unverified +} + +class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol { + + var completion: ((UserOtherSessionsViewModelResult) -> Void)? + private let sessionsInfo: [UserSessionInfo] + + init(sessionsInfo: [UserSessionInfo], + filter: OtherUserSessionsFilter, + title: String) { + self.sessionsInfo = sessionsInfo + super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: [])) + updateViewState(sessionsInfo: sessionsInfo, filter: filter) + } + + // MARK: - Public + + override func process(viewAction: UserOtherSessionsViewAction) { + switch viewAction { + case let .userOtherSessionSelected(sessionId: sessionId): + guard let session = sessionsInfo.first(where: {$0.id == sessionId}) else { + assertionFailure("Session should exist in the array.") + return + } + completion?(.showUserSessionOverview(sessionInfo: session)) + } + } + + // MARK: - Private + + private func updateViewState(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) { + let sectionItems = filterSessions(sessionsInfo: sessionsInfo, by: filter).asViewData() + let sectionHeader = createHeaderData(filter: filter) + state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)] + } + + private func filterSessions(sessionsInfo: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] { + switch filter { + case .all: + return sessionsInfo.filter { !$0.isCurrent } + case .inactive: + return sessionsInfo.filter { !$0.isActive } + case .unverified: + return sessionsInfo.filter { !$0.isVerified } + } + } + + private func createHeaderData(filter: OtherUserSessionsFilter) -> UserOtherSessionsHeaderViewData { + switch filter { + case .all: + // TODO: + return UserOtherSessionsHeaderViewData(title: nil, + subtitle: "", + iconName: nil) + case .inactive: + return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + case .unverified: + // TODO: + return UserOtherSessionsHeaderViewData(title: nil, + subtitle: "", + iconName: nil) + } + } +} + diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModelProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModelProtocol.swift new file mode 100644 index 0000000000..444fe1fc85 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol UserOtherSessionsViewModelProtocol { + var completion: ((UserOtherSessionsViewModelResult) -> Void)? { get set } + var context: UserOtherSessionsViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift new file mode 100644 index 0000000000..320a045987 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -0,0 +1,67 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct UserOtherSessions: View { + + @Environment(\.theme) private var theme + + @ObservedObject var viewModel: UserOtherSessionsViewModel.Context + + var body: some View { + ScrollView { + ForEach(viewModel.viewState.sections) { section in + switch section { + case let .sessionItems(header: header, items: items): + createSessionItemsSection(header: header, items: items) + } + } + } + .background(theme.colors.system.ignoresSafeArea()) + .frame(maxHeight: .infinity) + .navigationTitle(viewModel.viewState.title) + } + + private func createSessionItemsSection(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData]) -> some View { + SwiftUI.Section { + LazyVStack(spacing: 0) { + ForEach(items) { viewData in + UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in + viewModel.send(viewAction: .userOtherSessionSelected(sessionId: sessionId)) + }) + } + } + .background(theme.colors.background) + } header: { + UserOtherSessionsHeaderView(viewData: header) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24.0) + } + } +} + +// MARK: - Previews + +struct UserOtherSessions_Previews: PreviewProvider { + + static let stateRenderer = MockUserOtherSessionsScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift new file mode 100644 index 0000000000..d6a2b344b9 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -0,0 +1,83 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct UserOtherSessionsHeaderViewData: Hashable { + var title: String? + var subtitle: String + var iconName: String? +} + +struct UserOtherSessionsHeaderView: View { + + private var backgroundShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 8) + } + + @Environment(\.theme) private var theme + + let viewData: UserOtherSessionsHeaderViewData + + var body: some View { + HStack (alignment: .top, spacing: 0) { + if let iconName = viewData.iconName { + Image(iconName) + .foregroundColor(.red) + .frame(width: 40, height: 40) + .background(theme.colors.background) + .clipShape(backgroundShape) + .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape) + } + VStack(alignment: .leading, spacing: 0, content: { + if let title = viewData.title { + Text(title) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.primaryContent) + .padding(.vertical, 9.0) + } + Text(viewData.subtitle) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 20.0) + }) + .padding(.leading, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + } +} + +// MARK: - Previews + +struct UserOtherSessionsHeaderView_Previews: PreviewProvider { + + private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, + subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, + iconName: Asset.Images.userOtherSessionsInactive.name) + + + static var previews: some View { + Group { + UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData) + .theme(.light) + .preferredColorScheme(.light) + UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData) + .theme(.dark) + .preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift index bffc0ed568..20aaee1538 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift @@ -18,7 +18,7 @@ import CommonKit import SwiftUI struct UserSessionDetailsCoordinatorParameters { - let session: UserSessionInfo + let sessionInfo: UserSessionInfo } final class UserSessionDetailsCoordinator: Coordinator, Presentable { @@ -40,7 +40,7 @@ final class UserSessionDetailsCoordinator: Coordinator, Presentable { init(parameters: UserSessionDetailsCoordinatorParameters) { self.parameters = parameters - let viewModel = UserSessionDetailsViewModel(session: parameters.session) + let viewModel = UserSessionDetailsViewModel(sessionInfo: parameters.sessionInfo) let view = UserSessionDetails(viewModel: viewModel.context) userSessionDetailsViewModel = viewModel userSessionDetailsHostingController = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index 90fa84a36d..ea23cde617 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -38,10 +38,10 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let session: UserSessionInfo + let sessionInfo: UserSessionInfo switch self { case .allSections: - session = UserSessionInfo(id: "alice", + sessionInfo = UserSessionInfo(id: "alice", name: "iOS", deviceType: .mobile, isVerified: false, @@ -58,7 +58,7 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { isActive: true, isCurrent: true) case .sessionSectionOnly: - session = UserSessionInfo(id: "3", + sessionInfo = UserSessionInfo(id: "3", name: "Android", deviceType: .mobile, isVerified: false, @@ -75,12 +75,12 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { isActive: true, isCurrent: false) } - let viewModel = UserSessionDetailsViewModel(session: session) + let viewModel = UserSessionDetailsViewModel(sessionInfo: sessionInfo) // can simulate service and viewModel actions here if needs be. return ( - [session], + [sessionInfo], AnyView(UserSessionDetails(viewModel: viewModel.context)) ) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift index b07751c100..524dece67a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift @@ -35,7 +35,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { ] let expectedModel = UserSessionDetailsViewState(sections: sections) - let sut = UserSessionDetailsViewModel(session: userSessionInfo) + let sut = UserSessionDetailsViewModel(sessionInfo: userSessionInfo) XCTAssertEqual(sut.state, expectedModel) } @@ -57,7 +57,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { ] let expectedModel = UserSessionDetailsViewState(sections: sections) - let sut = UserSessionDetailsViewModel(session: userSessionInfo) + let sut = UserSessionDetailsViewModel(sessionInfo: userSessionInfo) XCTAssertEqual(sut.state, expectedModel) } @@ -94,7 +94,7 @@ class UserSessionDetailsViewModelTests: XCTestCase { ] let expectedModel = UserSessionDetailsViewState(sections: sections) - let sut = UserSessionDetailsViewModel(session: userSessionInfo) + let sut = UserSessionDetailsViewModel(sessionInfo: userSessionInfo) XCTAssertEqual(sut.state, expectedModel) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift index f08ecdd8e4..60732166fa 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift @@ -21,59 +21,59 @@ typealias UserSessionDetailsViewModelType = StateStoreViewModel Void)? - init(session: UserSessionInfo) { + init(sessionInfo: UserSessionInfo) { super.init(initialViewState: UserSessionDetailsViewState(sections: [])) - updateViewState(session: session) + updateViewState(sessionInfo: sessionInfo) } // MARK: - Public // MARK: - Private - private func updateViewState(session: UserSessionInfo) { + private func updateViewState(sessionInfo: UserSessionInfo) { var sections = [UserSessionDetailsSectionViewData]() - sections.append(sessionSection(session: session)) + sections.append(sessionSection(sessionInfo: sessionInfo)) - if let applicationSection = applicationSection(session: session) { + if let applicationSection = applicationSection(sessionInfo: sessionInfo) { sections.append(applicationSection) } - if let deviceSection = deviceSection(session: session) { + if let deviceSection = deviceSection(sessionInfo: sessionInfo) { sections.append(deviceSection) } state = UserSessionDetailsViewState(sections: sections) } - private func sessionSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData { + private func sessionSection(sessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData { var sessionItems: [UserSessionDetailsSectionItemViewData] = [] - if let sessionName = session.name { + if let sessionName = sessionInfo.name { sessionItems.append(.init(title: VectorL10n.userSessionDetailsSessionName, value: sessionName)) } sessionItems.append(.init(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, - value: session.id)) + value: sessionInfo.id)) return .init(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), footer: VectorL10n.userSessionDetailsSessionSectionFooter, items: sessionItems) } - private func applicationSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? { + private func applicationSection(sessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData? { var sessionItems: [UserSessionDetailsSectionItemViewData] = [] - if let name = session.applicationName { + if let name = sessionInfo.applicationName { sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationName, value: name)) } - if let version = session.applicationVersion { + if let version = sessionInfo.applicationVersion { sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationVersion, value: version)) } - if let url = session.applicationURL { + if let url = sessionInfo.applicationURL { sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationUrl, value: url)) } @@ -86,28 +86,28 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD items: sessionItems) } - private func deviceSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? { + private func deviceSection(sessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData? { var deviceSectionItems = [UserSessionDetailsSectionItemViewData]() - if let model = session.deviceModel { + if let model = sessionInfo.deviceModel { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceModel, value: model)) } - if session.deviceType == .web, - let clientName = session.clientName, - let clientVersion = session.clientVersion { + if sessionInfo.deviceType == .web, + let clientName = sessionInfo.clientName, + let clientVersion = sessionInfo.clientVersion { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceBrowser, value: "\(clientName) \(clientVersion)")) } - if let deviceOS = session.deviceOS { + if let deviceOS = sessionInfo.deviceOS { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceOs, value: deviceOS)) } - if let lastSeenIP = session.lastSeenIP { + if let lastSeenIP = sessionInfo.lastSeenIP { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpAddress, value: lastSeenIP)) } - if let lastSeenIPLocation = session.lastSeenIPLocation { + if let lastSeenIPLocation = sessionInfo.lastSeenIPLocation { deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpLocation, value: lastSeenIPLocation)) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift index a2d0e78078..6f1859dd25 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift @@ -20,10 +20,9 @@ import XCTest @testable import RiotSwiftUI class UserSessionOverviewViewModelTests: XCTestCase { - var sut: UserSessionOverviewViewModel! func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() { - sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService()) + let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService()) XCTAssertEqual(sut.state.isPusherEnabled, nil) var modelResult: UserSessionOverviewViewModelResult? @@ -36,7 +35,7 @@ class UserSessionOverviewViewModelTests: XCTestCase { func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() { let sessionInfo = createUserSessionInfo() - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService()) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService()) XCTAssertEqual(sut.state.isPusherEnabled, nil) var modelResult: UserSessionOverviewViewModelResult? @@ -46,11 +45,11 @@ class UserSessionOverviewViewModelTests: XCTestCase { sut.process(viewAction: .viewSessionDetails) XCTAssertEqual(modelResult, .showSessionDetails(sessionInfo: sessionInfo)) } - + func test_whenViewSessionDetailsProcessed_toggleAvailablePusher() { let sessionInfo = createUserSessionInfo() let service = MockUserSessionOverviewService(pusherEnabled: true) - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) XCTAssertTrue(sut.state.remotelyTogglingPushersAvailable) XCTAssertEqual(sut.state.isPusherEnabled, true) @@ -63,7 +62,7 @@ class UserSessionOverviewViewModelTests: XCTestCase { func test_whenViewSessionDetailsProcessed_toggleNoPusher() { let sessionInfo = createUserSessionInfo() let service = MockUserSessionOverviewService(pusherEnabled: nil) - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) XCTAssertTrue(sut.state.remotelyTogglingPushersAvailable) XCTAssertEqual(sut.state.isPusherEnabled, nil) @@ -76,7 +75,7 @@ class UserSessionOverviewViewModelTests: XCTestCase { func test_whenViewSessionDetailsProcessed_remotelyTogglingPushersNotAvailable() { let sessionInfo = createUserSessionInfo() let service = MockUserSessionOverviewService(pusherEnabled: true, remotelyTogglingPushersAvailable: false) - sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) + let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service) XCTAssertFalse(sut.state.remotelyTogglingPushersAvailable) XCTAssertEqual(sut.state.isPusherEnabled, true) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 1a72a45c6b..443088ad0f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -53,16 +53,12 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).") switch result { - case .showAllUnverifiedSessions: - self.showAllUnverifiedSessions() - case .showAllInactiveSessions: - self.showAllInactiveSessions() + case let .showOtherSessions(sessionsInfo: sessionsInfo, filter: filter): + self.showOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter) case .verifyCurrentSession: self.startVerifyCurrentSession() case let .showCurrentSessionOverview(sessionInfo): self.showCurrentSessionOverview(sessionInfo: sessionInfo) - case .showAllOtherSessions: - self.showAllOtherSessions() case let .showUserSessionOverview(sessionInfo): self.showUserSessionOverview(sessionInfo: sessionInfo) } @@ -88,12 +84,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { loadingIndicator = nil } - private func showAllUnverifiedSessions() { - // TODO: - } - - private func showAllInactiveSessions() { - // TODO: + private func showOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) { + completion?(.openOtherSessions(sessionsInfo: sessionsInfo, filter: filter)) } private func startVerifyCurrentSession() { @@ -103,12 +95,9 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { private func showCurrentSessionOverview(sessionInfo: UserSessionInfo) { completion?(.openSessionOverview(sessionInfo: sessionInfo)) } - + private func showUserSessionOverview(sessionInfo: UserSessionInfo) { completion?(.openSessionOverview(sessionInfo: sessionInfo)) } - - private func showAllOtherSessions() { - // TODO: - } + } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index a376077866..5a87dd27bb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -101,7 +101,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { deviceType: .desktop, isVerified: verified, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift index 21389eafd3..8768f0fcd3 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift @@ -49,15 +49,9 @@ class UserSessionsOverviewViewModelTests: XCTestCase { viewModel.process(viewAction: .verifyCurrentSession) XCTAssertEqual(result, .verifyCurrentSession) - - viewModel.process(viewAction: .viewAllUnverifiedSessions) - XCTAssertEqual(result, .showAllUnverifiedSessions) viewModel.process(viewAction: .viewAllInactiveSessions) - XCTAssertEqual(result, .showAllInactiveSessions) - - viewModel.process(viewAction: .viewAllOtherSessions) - XCTAssertEqual(result, .showAllOtherSessions) + XCTAssertEqual(result, .showOtherSessions(sessionsInfo: [], filter: .inactive)) } func testShowSessionDetails() { @@ -77,7 +71,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { } viewModel.process(viewAction: .viewCurrentSessionDetails) - XCTAssertEqual(result, .showCurrentSessionOverview(session: currentSession)) + XCTAssertEqual(result, .showCurrentSessionOverview(sessionInfo: currentSession)) guard let randomSession = service.overviewData.otherSessions.randomElement() else { XCTFail("There should be other sessions") @@ -85,6 +79,6 @@ class UserSessionsOverviewViewModelTests: XCTestCase { } viewModel.process(viewAction: .tapUserSession(randomSession.id)) - XCTAssertEqual(result, .showUserSessionOverview(session: randomSession)) + XCTAssertEqual(result, .showUserSessionOverview(sessionInfo: randomSession)) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index 812554a3c5..4f87867688 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -20,17 +20,16 @@ import Foundation enum UserSessionsOverviewCoordinatorResult { case openSessionOverview(sessionInfo: UserSessionInfo) + case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) } // MARK: View model enum UserSessionsOverviewViewModelResult: Equatable { - case showAllUnverifiedSessions - case showAllInactiveSessions + case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) case verifyCurrentSession - case showCurrentSessionOverview(session: UserSessionInfo) - case showAllOtherSessions - case showUserSessionOverview(session: UserSessionInfo) + case showCurrentSessionOverview(sessionInfo: UserSessionInfo) + case showUserSessionOverview(sessionInfo: UserSessionInfo) } // MARK: View diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index c44c2b6fac..72e0168a33 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -44,19 +44,21 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess assertionFailure("Missing current session") return } - completion?(.showCurrentSessionOverview(session: currentSessionInfo)) + completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo)) case .viewAllUnverifiedSessions: - completion?(.showAllUnverifiedSessions) + // TODO: showSessions(filteredBy: .unverified) + break case .viewAllInactiveSessions: - completion?(.showAllInactiveSessions) + showSessions(filteredBy: .inactive) case .viewAllOtherSessions: - completion?(.showAllOtherSessions) + // TODO: showSessions(filteredBy: .all) + break case .tapUserSession(let sessionId): guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else { assertionFailure("Missing session info") return } - completion?(.showUserSessionOverview(session: session)) + completion?(.showUserSessionOverview(sessionInfo: session)) } } @@ -91,10 +93,15 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess } } } + + private func showSessions(filteredBy filter: OtherUserSessionsFilter) { + completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.overviewData.otherSessions, + filter: filter)) + } } -private extension Collection where Element == UserSessionInfo { +extension Collection where Element == UserSessionInfo { func asViewData() -> [UserSessionListItemViewData] { - map { UserSessionListItemViewData(session: $0) } + map { UserSessionListItemViewDataFactory().create(from: $0)} } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift index 51a698541e..001b658b5d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift @@ -42,11 +42,16 @@ struct UserSessionListItem: View { .font(theme.fonts.bodySB) .foregroundColor(theme.colors.primaryContent) .multilineTextAlignment(.leading) - - Text(viewData.sessionDetails) - .font(theme.fonts.caption1) - .foregroundColor(theme.colors.secondaryContent) - .multilineTextAlignment(.leading) + HStack { + if let sessionDetailsIcon = viewData.sessionDetailsIcon { + Image(sessionDetailsIcon) + .padding(.leading, 2) + } + Text(viewData.sessionDetails) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.secondaryContent) + .multilineTextAlignment(.leading) + } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -69,7 +74,7 @@ struct UserSessionListPreview: View { var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in - let viewData = UserSessionListItemViewData(session: userSessionInfo) + let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo) UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift index af13e572b4..da89c88925 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -17,59 +17,19 @@ import Foundation /// View data for UserSessionListItem -struct UserSessionListItemViewData: Identifiable { +struct UserSessionListItemViewData: Identifiable, Hashable { + var id: String { sessionId } let sessionId: String - + let sessionName: String let sessionDetails: String let deviceAvatarViewData: DeviceAvatarViewData - - init(sessionId: String, - sessionDisplayName: String?, - deviceType: DeviceType, - isVerified: Bool, - lastActivityDate: TimeInterval?) { - self.sessionId = sessionId - sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) - sessionDetails = Self.buildSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate) - deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: isVerified) - } - - // MARK: - Private - - private static func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String { - let sessionDetailsString: String - - let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort - - var lastActivityDateString: String? - - if let lastActivityDate = lastActivityDate { - lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) - } - if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { - sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString) - } else { - sessionDetailsString = sessionStatusText - } - - return sessionDetailsString - } -} - -extension UserSessionListItemViewData { - init(session: UserSessionInfo) { - self.init(sessionId: session.id, - sessionDisplayName: session.name, - deviceType: session.deviceType, - isVerified: session.isVerified, - lastActivityDate: session.lastSeenTimestamp) - } + let sessionDetailsIcon: String? } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift new file mode 100644 index 0000000000..49d79433ae --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -0,0 +1,75 @@ +// +// 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 + +struct UserSessionListItemViewDataFactory { + + func create(from session: UserSessionInfo) -> UserSessionListItemViewData { + let sessionName = UserSessionNameFormatter.sessionName(deviceType: session.deviceType, + sessionDisplayName: session.name) + let sessionDetails = buildSessionDetails(isVerified: session.isVerified, + lastActivityDate: session.lastSeenTimestamp, + isActive: session.isActive) + let deviceAvatarViewData = DeviceAvatarViewData(deviceType: session.deviceType, + isVerified: session.isVerified) + return UserSessionListItemViewData(sessionId: session.id, + sessionName: sessionName, + sessionDetails: sessionDetails, + deviceAvatarViewData: deviceAvatarViewData, + sessionDetailsIcon: getSessionDetailsIcon(isActive: session.isActive)) + } + + private func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?, isActive: Bool) -> String { + if isActive { + return activeSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate) + } else { + return inactiveSessionDetails(lastActivityDate: lastActivityDate) + } + } + + private func inactiveSessionDetails(lastActivityDate: TimeInterval?) -> String { + if let lastActivityDate = lastActivityDate { + let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) + return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString) + } + return VectorL10n.userInactiveSessionItem + } + + private func activeSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String { + let sessionDetailsString: String + + let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort + + var lastActivityDateString: String? + + if let lastActivityDate = lastActivityDate { + lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) + } + + if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { + sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString) + } else { + sessionDetailsString = sessionStatusText + } + + return sessionDetailsString + } + + private func getSessionDetailsIcon(isActive: Bool) -> String? { + isActive ? nil : Asset.Images.userSessionListItemInactiveSession.name + } +} diff --git a/changelog.d/6786.wip b/changelog.d/6786.wip new file mode 100644 index 0000000000..0f1def9f76 --- /dev/null +++ b/changelog.d/6786.wip @@ -0,0 +1 @@ +Device manager: Inactive sessions screen.