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.