Release 2.0.0

This commit is contained in:
Frank Rotermund
2022-11-27 13:18:53 +00:00
parent bf57719009
commit 0dc8ec0982
570 changed files with 20366 additions and 4410 deletions
@@ -19,6 +19,7 @@ import SwiftUI
struct UserSessionsOverviewCoordinatorParameters {
let session: MXSession
let service: UserSessionsOverviewService
}
final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
@@ -36,11 +37,14 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsOverviewCoordinatorParameters) {
self.parameters = parameters
service = parameters.service
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: parameters.service)
let dataProvider = UserSessionsDataProvider(session: parameters.session)
service = UserSessionsOverviewService(dataProvider: dataProvider)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
hostingViewController.vc_setLargeTitleDisplayMode(.never)
hostingViewController.vc_removeBackTitle()
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController)
}
@@ -53,18 +57,20 @@ 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(sessionInfos: sessionInfos, filter: filter):
self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
case .verifyCurrentSession:
self.startVerifyCurrentSession()
self.completion?(.verifyCurrentSession)
case .renameSession(let sessionInfo):
self.completion?(.renameSession(sessionInfo))
case .logoutOfSession(let sessionInfo):
self.completion?(.logoutOfSession(sessionInfo))
case let .showCurrentSessionOverview(sessionInfo):
self.showCurrentSessionOverview(sessionInfo: sessionInfo)
case .showAllOtherSessions:
self.showAllOtherSessions()
case let .showUserSessionOverview(sessionInfo):
self.showUserSessionOverview(sessionInfo: sessionInfo)
case .linkDevice:
self.completion?(.linkDevice)
}
}
}
@@ -88,12 +94,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
loadingIndicator = nil
}
private func showAllUnverifiedSessions() {
// TODO:
}
private func showAllInactiveSessions() {
// TODO:
private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: UserOtherSessionsFilter) {
completion?(.openOtherSessions(sessionInfos: sessionInfos, filter: filter))
}
private func startVerifyCurrentSession() {
@@ -103,12 +105,8 @@ 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:
}
}
@@ -44,7 +44,23 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
session.crypto.device(withDeviceId: deviceId, ofUser: userId)
}
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState {
guard let deviceInfo = deviceInfo else { return .unknown }
guard session.crypto?.crossSigning.canCrossSign == true else {
return deviceInfo.deviceId == session.myDeviceId ? .unverified : .unknown
}
return deviceInfo.trustLevel.isVerified ? .verified : .unverified
}
func accountData(for eventType: String) -> [AnyHashable: Any]? {
session.accountData.accountData(forEventType: eventType)
}
func qrLoginAvailable() async throws -> Bool {
let service = QRLoginService(client: session.matrixRestClient,
mode: .authenticated)
return try await service.isServiceAvailable()
}
}
@@ -28,5 +28,9 @@ protocol UserSessionsDataProviderProtocol {
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo?
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState
func accountData(for eventType: String) -> [AnyHashable: Any]?
func qrLoginAvailable() async throws -> Bool
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
import MatrixSDK
class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
@@ -22,18 +22,22 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400
private let dataProvider: UserSessionsDataProviderProtocol
private var cancellables: Set<AnyCancellable> = []
private(set) var overviewData: UserSessionsOverviewData
private(set) var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
private(set) var sessionInfos: [UserSessionInfo]
init(dataProvider: UserSessionsDataProviderProtocol) {
self.dataProvider = dataProvider
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
sessionInfos = []
setupInitialOverviewData()
listenForSessionUpdates()
}
// MARK: - Public
@@ -42,8 +46,14 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
dataProvider.devices { response in
switch response {
case .success(let devices):
self.overviewData = self.sessionsOverviewData(from: devices)
completion(.success(self.overviewData))
self.sessionInfos = self.sortedSessionInfos(from: devices)
Task { @MainActor in
let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable()
let overviewData = self.sessionsOverviewData(from: self.sessionInfos,
linkDeviceEnabled: linkDeviceEnabled ?? false)
self.overviewDataPublisher.send(overviewData)
completion(.success(overviewData))
}
case .failure(let error):
completion(.failure(error))
}
@@ -51,24 +61,43 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
if overviewData.currentSession?.id == sessionId {
return overviewData.currentSession
if currentSession?.id == sessionId {
return currentSession
}
return overviewData.otherSessions.first(where: { $0.id == sessionId })
return otherSessions.first(where: { $0.id == sessionId })
}
// MARK: - Private
private func listenForSessionUpdates() {
NotificationCenter.default.publisher(for: .MXDeviceInfoTrustLevelDidChange)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .MXDeviceListDidUpdateUsersDevices)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .MXCrossSigningInfoTrustLevelDidChange)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
}
private func setupInitialOverviewData() {
guard let currentSessionInfo = getCurrentSessionInfo() else {
return
}
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [])
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.verificationState == .verified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [],
linkDeviceEnabled: false))
}
private func getCurrentSessionInfo() -> UserSessionInfo? {
@@ -78,20 +107,25 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
}
return sessionInfo(from: device, isCurrentSession: true)
}
private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
let allSessions = devices
private func sortedSessionInfos(from devices: [MXDevice]) -> [UserSessionInfo] {
devices
.sorted { $0.lastSeenTs > $1.lastSeenTs }
.map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) }
return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified },
inactiveSessions: allSessions.filter { !$0.isActive },
otherSessions: allSessions.filter { !$0.isCurrent })
}
private func sessionsOverviewData(from allSessions: [UserSessionInfo],
linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { $0.verificationState == .unverified && !$0.isCurrent },
inactiveSessions: allSessions.filter { !$0.isActive },
otherSessions: allSessions.filter { !$0.isCurrent },
linkDeviceEnabled: linkDeviceEnabled)
}
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false
let deviceInfo = deviceInfo(for: device.deviceId)
let verificationState = dataProvider.verificationState(for: deviceInfo)
let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId
let appData = dataProvider.accountData(for: eventType)
@@ -110,7 +144,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
return UserSessionInfo(withDevice: device,
applicationData: appData as? [String: String],
userAgent: userAgent,
isSessionVerified: isSessionVerified,
verificationState: verificationState,
isActive: isSessionActive,
isCurrent: isCurrentSession)
}
@@ -128,13 +162,13 @@ extension UserSessionInfo {
init(withDevice device: MXDevice,
applicationData: [String: String]?,
userAgent: UserAgent?,
isSessionVerified: Bool,
verificationState: VerificationState,
isActive: Bool,
isCurrent: Bool) {
self.init(id: device.deviceId,
name: device.displayName,
deviceType: userAgent?.deviceType ?? .unknown,
isVerified: isSessionVerified,
verificationState: verificationState,
lastSeenIP: device.lastSeenIp,
lastSeenTimestamp: device.lastSeenTs > 0 ? TimeInterval(device.lastSeenTs / 1000) : nil,
applicationName: applicationData?["name"],
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
enum Mode {
@@ -27,15 +27,17 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private let mode: Mode
var overviewData: UserSessionsOverviewData
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
var sessionInfos = [UserSessionInfo]()
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
}
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
@@ -44,43 +46,47 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
switch mode {
case .noOtherSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
case .onlyUnverifiedSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions + [currentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: unverifiedSessions + [mockCurrentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions,
linkDeviceEnabled: false))
case .onlyInactiveSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions,
linkDeviceEnabled: false))
default:
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions,
linkDeviceEnabled: true))
}
completion(.success(overviewData))
completion(.success(overviewDataPublisher.value))
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
overviewData.otherSessions.first { $0.id == sessionId }
otherSessions.first { $0.id == sessionId }
}
// MARK: - Private
private var currentSession: UserSessionInfo {
private var mockCurrentSession: UserSessionInfo {
UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: mode == .currentSessionVerified,
verificationState: mode == .currentSessionVerified ? .verified : .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
@@ -99,14 +105,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
[UserSessionInfo(id: "1 verified: \(verified) active: \(active)",
name: "macOS verified: \(verified) active: \(active)",
deviceType: .desktop,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
deviceOS: "macOS",
lastSeenIPLocation: nil,
clientName: "Electron",
clientVersion: "20.0.0",
@@ -115,14 +121,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
UserSessionInfo(id: "2 verified: \(verified) active: \(active)",
name: "Firefox on Windows verified: \(verified) active: \(active)",
deviceType: .web,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: "Element Web",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Windows 10",
deviceOS: "Windows",
lastSeenIPLocation: nil,
clientName: "Firefox",
clientVersion: "39.0",
@@ -131,7 +137,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
UserSessionInfo(id: "3 verified: \(verified) active: \(active)",
name: "Android verified: \(verified) active: \(active)",
deviceType: .mobile,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
@@ -14,19 +14,34 @@
// limitations under the License.
//
import Foundation
import Combine
struct UserSessionsOverviewData {
let currentSession: UserSessionInfo?
let unverifiedSessions: [UserSessionInfo]
let inactiveSessions: [UserSessionInfo]
let otherSessions: [UserSessionInfo]
let linkDeviceEnabled: Bool
}
protocol UserSessionsOverviewServiceProtocol {
var overviewData: UserSessionsOverviewData { get }
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never> { get }
var sessionInfos: [UserSessionInfo] { get }
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) -> Void
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo?
}
extension UserSessionsOverviewServiceProtocol {
/// The user's current session.
var currentSession: UserSessionInfo? { overviewDataPublisher.value.currentSession }
/// Any unverified sessions on the user's account.
var unverifiedSessions: [UserSessionInfo] { overviewDataPublisher.value.unverifiedSessions }
/// Any inactive sessions on the user's account (not seen for a while).
var inactiveSessions: [UserSessionInfo] { overviewDataPublisher.value.inactiveSessions }
/// Any sessions that are verified and have been seen recently.
var otherSessions: [UserSessionInfo] { overviewDataPublisher.value.otherSessions }
/// Whether it is possible to link a new device via a QR code.
var linkDeviceEnabled: Bool { overviewDataPublisher.value.linkDeviceEnabled }
}
@@ -23,6 +23,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
verifyLinkDeviceButtonStatus(true)
}
func testCurrentSessionVerified() {
@@ -30,6 +32,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
verifyLinkDeviceButtonStatus(true)
}
func testOnlyUnverifiedSessions() {
@@ -37,6 +41,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
verifyLinkDeviceButtonStatus(false)
}
func testOnlyInactiveSessions() {
@@ -44,6 +50,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
verifyLinkDeviceButtonStatus(false)
}
func testNoOtherSessions() {
@@ -51,5 +59,32 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists)
verifyLinkDeviceButtonStatus(false)
}
func verifyLinkDeviceButtonStatus(_ enabled: Bool) {
// if enabled {
// let linkDeviceButton = app.buttons["linkDeviceButton"]
// XCTAssertTrue(linkDeviceButton.exists)
// XCTAssertTrue(linkDeviceButton.isEnabled)
// } else {
// let linkDeviceButton = app.buttons["linkDeviceButton"]
// XCTAssertFalse(linkDeviceButton.exists)
// }
}
func testWhenMoreThan5OtherSessionsThenViewAllButtonVisible() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionUnverified.title)
app.swipeUp()
XCTAssertTrue(app.buttons["ViewAllButton"].exists)
}
func testWhenLessThan5OtherSessionsThenViewAllButtonHidden() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyUnverifiedSessions.title)
app.swipeUp()
XCTAssertFalse(app.buttons["ViewAllButton"].exists)
}
}
@@ -0,0 +1,114 @@
//
// 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 Combine
import XCTest
@testable import RiotSwiftUI
class UserSessionListItemViewDataFactoryTests: XCTestCase {
let factory = UserSessionListItemViewDataFactory()
func testSessionDetailsWithTimestamp() {
// Given other devices in each of the verification states.
let sessionInfoVerified = UserSessionInfo.mockPhone(verificationState: .verified)
let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified)
let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown)
// When getting session details for each of them.
let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails
let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails
let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails
// Then the details should be formatted correctly.
let lastActivityString = UserSessionLastActivityFormatter.lastActivityDateString(from: sessionInfoVerified.lastSeenTimestamp!)
XCTAssertEqual(sessionDetailsVerified,
VectorL10n.userSessionItemDetails(VectorL10n.userSessionVerifiedShort, VectorL10n.userSessionItemDetailsLastActivity(lastActivityString)),
"The details should show as verified with a last activity string when verified.")
XCTAssertEqual(sessionDetailsUnverified,
VectorL10n.userSessionItemDetails(VectorL10n.userSessionUnverifiedShort, VectorL10n.userSessionItemDetailsLastActivity(lastActivityString)),
"The details should show as unverified with a last activity string when unverified.")
XCTAssertEqual(sessionDetailsUnknown,
VectorL10n.userSessionItemDetailsLastActivity(lastActivityString),
"The details should only show the last activity string when verification is unknown.")
}
func testSessionDetailsVerifiedWithoutTimestamp() {
// Given a verified other device
let sessionInfoVerified = UserSessionInfo.mockPhone(hasTimestamp: false)
let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified, hasTimestamp: false)
let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown, hasTimestamp: false)
// When getting session details
let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails
let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails
let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails
// Then the details should contain the verification state and a last seen date.
XCTAssertEqual(sessionDetailsVerified, VectorL10n.userSessionVerifiedShort,
"The details should only show the verification state when no timestamp exists.")
XCTAssertEqual(sessionDetailsUnverified, VectorL10n.userSessionUnverifiedShort,
"The details should only show the verification state when no timestamp exists.")
XCTAssertEqual(sessionDetailsUnknown, VectorL10n.userSessionVerificationUnknownShort,
"The details should only show the verification state when no timestamp exists.")
}
func testCurrentSessionDetailsWithTimestamp() {
// Given other devices in each of the verification states.
let sessionInfoVerified = UserSessionInfo.mockPhone(verificationState: .verified, isCurrent: true)
let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified, isCurrent: true)
let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown, isCurrent: true)
// When getting session details for each of them.
let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails
let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails
let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails
// Then the details should be formatted correctly.
XCTAssertEqual(sessionDetailsVerified,
VectorL10n.userSessionItemDetails(VectorL10n.userSessionVerifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails),
"The details should show as verified with a current session string when verified.")
XCTAssertEqual(sessionDetailsUnverified,
VectorL10n.userSessionItemDetails(VectorL10n.userSessionUnverifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails),
"The details should show as unverified with a current session string when unverified.")
XCTAssertEqual(sessionDetailsUnknown,
VectorL10n.userOtherSessionCurrentSessionDetails,
"The details should only show the current session string when verification is unknown.")
}
func testCurrentSessionDetailsVerifiedWithoutTimestamp() {
// Given a verified other device
let sessionInfoVerified = UserSessionInfo.mockPhone(hasTimestamp: false, isCurrent: true)
let sessionInfoUnverified = UserSessionInfo.mockPhone(verificationState: .unverified, hasTimestamp: false, isCurrent: true)
let sessionInfoUnknown = UserSessionInfo.mockPhone(verificationState: .unknown, hasTimestamp: false, isCurrent: true)
// When getting session details
let sessionDetailsVerified = factory.create(from: sessionInfoVerified).sessionDetails
let sessionDetailsUnverified = factory.create(from: sessionInfoUnverified).sessionDetails
let sessionDetailsUnknown = factory.create(from: sessionInfoUnknown).sessionDetails
// Then the details should contain the verification state and a last seen date.
XCTAssertEqual(sessionDetailsVerified,
VectorL10n.userSessionItemDetails(VectorL10n.userSessionVerifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails),
"The details should show as verified with a current session string when verified.")
XCTAssertEqual(sessionDetailsUnverified,
VectorL10n.userSessionItemDetails(VectorL10n.userSessionUnverifiedShort, VectorL10n.userOtherSessionCurrentSessionDetails),
"The details should show as unverified with a current session string when unverified.")
XCTAssertEqual(sessionDetailsUnknown,
VectorL10n.userOtherSessionCurrentSessionDetails,
"The details should only show the current session string when verification is unknown.")
}
}
@@ -27,6 +27,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.linkDeviceButtonVisible)
}
func testLoadOnDidAppear() {
@@ -37,6 +38,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.linkDeviceButtonVisible)
}
func testSimpleActionProcessing() {
@@ -49,15 +51,18 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
viewModel.process(viewAction: .verifyCurrentSession)
XCTAssertEqual(result, .verifyCurrentSession)
viewModel.process(viewAction: .viewAllUnverifiedSessions)
XCTAssertEqual(result, .showAllUnverifiedSessions)
result = nil
viewModel.process(viewAction: .viewAllInactiveSessions)
XCTAssertEqual(result, .showAllInactiveSessions)
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive))
result = nil
viewModel.process(viewAction: .viewAllOtherSessions)
XCTAssertEqual(result, .showAllOtherSessions)
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .all))
result = nil
viewModel.process(viewAction: .linkDevice)
XCTAssertEqual(result, .linkDevice)
}
func testShowSessionDetails() {
@@ -71,20 +76,20 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
result = action
}
guard let currentSession = service.overviewData.currentSession else {
guard let currentSession = service.currentSession else {
XCTFail("The current session should be valid at this point")
return
}
viewModel.process(viewAction: .viewCurrentSessionDetails)
XCTAssertEqual(result, .showCurrentSessionOverview(session: currentSession))
XCTAssertEqual(result, .showCurrentSessionOverview(sessionInfo: currentSession))
guard let randomSession = service.overviewData.otherSessions.randomElement() else {
guard let randomSession = service.otherSessions.randomElement() else {
XCTFail("There should be other sessions")
return
}
viewModel.process(viewAction: .tapUserSession(randomSession.id))
XCTAssertEqual(result, .showUserSessionOverview(session: randomSession))
XCTAssertEqual(result, .showUserSessionOverview(sessionInfo: randomSession))
}
}
@@ -19,18 +19,24 @@ import Foundation
// MARK: - Coordinator
enum UserSessionsOverviewCoordinatorResult {
case verifyCurrentSession
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case openSessionOverview(sessionInfo: UserSessionInfo)
case openOtherSessions(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter)
case linkDevice
}
// MARK: View model
enum UserSessionsOverviewViewModelResult: Equatable {
case showAllUnverifiedSessions
case showAllInactiveSessions
case showOtherSessions(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter)
case verifyCurrentSession
case showCurrentSessionOverview(session: UserSessionInfo)
case showAllOtherSessions
case showUserSessionOverview(session: UserSessionInfo)
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case showCurrentSessionOverview(sessionInfo: UserSessionInfo)
case showUserSessionOverview(sessionInfo: UserSessionInfo)
case linkDevice
}
// MARK: View
@@ -45,14 +51,19 @@ struct UserSessionsOverviewViewState: BindableState {
var otherSessionsViewData = [UserSessionListItemViewData]()
var showLoadingIndicator = false
var linkDeviceButtonVisible = false
}
enum UserSessionsOverviewViewAction {
case viewAppeared
case verifyCurrentSession
case renameCurrentSession
case logoutOfCurrentSession
case viewCurrentSessionDetails
case viewAllUnverifiedSessions
case viewAllInactiveSessions
case viewAllOtherSessions
case tapUserSession(_ sessionId: String)
case linkDevice
}
@@ -20,7 +20,7 @@ typealias UserSessionsOverviewViewModelType = StateStoreViewModel<UserSessionsOv
class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSessionsOverviewViewModelProtocol {
private let userSessionsOverviewService: UserSessionsOverviewServiceProtocol
var completion: ((UserSessionsOverviewViewModelResult) -> Void)?
init(userSessionsOverviewService: UserSessionsOverviewServiceProtocol) {
@@ -28,7 +28,12 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
super.init(initialViewState: .init())
updateViewState(with: userSessionsOverviewService.overviewData)
userSessionsOverviewService.overviewDataPublisher.sink { [weak self] overviewData in
self?.updateViewState(with: overviewData)
}
.store(in: &cancellables)
updateViewState(with: userSessionsOverviewService.overviewDataPublisher.value)
}
// MARK: - Public
@@ -39,24 +44,38 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
loadData()
case .verifyCurrentSession:
completion?(.verifyCurrentSession)
case .viewCurrentSessionDetails:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
case .renameCurrentSession:
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.showCurrentSessionOverview(session: currentSessionInfo))
completion?(.renameSession(currentSessionInfo))
case .logoutOfCurrentSession:
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.logoutOfSession(currentSessionInfo))
case .viewCurrentSessionDetails:
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo))
case .viewAllUnverifiedSessions:
completion?(.showAllUnverifiedSessions)
showSessions(filteredBy: .unverified)
case .viewAllInactiveSessions:
completion?(.showAllInactiveSessions)
showSessions(filteredBy: .inactive)
case .viewAllOtherSessions:
completion?(.showAllOtherSessions)
showSessions(filteredBy: .all)
case .tapUserSession(let sessionId):
guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else {
assertionFailure("Missing session info")
return
}
completion?(.showUserSessionOverview(session: session))
completion?(.showUserSessionOverview(sessionInfo: session))
case .linkDevice:
completion?(.linkDevice)
}
}
@@ -70,31 +89,33 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
if let currentSessionInfo = userSessionsViewData.currentSession {
state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo)
}
state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled
}
private func loadData() {
state.showLoadingIndicator = true
userSessionsOverviewService.updateOverviewData { [weak self] result in
guard let self = self else {
return
}
guard let self = self else { return }
self.state.showLoadingIndicator = false
switch result {
case .success(let overViewData):
self.updateViewState(with: overViewData)
case .failure(let error):
if case let .failure(error) = result {
// TODO:
break
}
// No need to consume .success as there's a subscription on the data.
}
}
private func showSessions(filteredBy filter: UserOtherSessionsFilter) {
completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos,
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) }
}
}
@@ -25,54 +25,76 @@ struct UserSessionListItem: View {
}
@Environment(\.theme) private var theme: ThemeSwiftUI
let viewData: UserSessionListItemViewData
var isEditModeEnabled = false
var onBackgroundTap: ((String) -> Void)?
var onBackgroundLongPress: ((String) -> Void)?
var body: some View {
Button {
onBackgroundTap?(viewData.sessionId)
} label: {
VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) {
HStack(spacing: LayoutConstants.avatarRightMargin) {
DeviceAvatarView(viewData: viewData.deviceAvatarViewData)
VStack(alignment: .leading, spacing: 2) {
Text(viewData.sessionName)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.leading)
Text(viewData.sessionDetails)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.secondaryContent)
.multilineTextAlignment(.leading)
}
Button { } label: {
ZStack {
if viewData.isSelected {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(theme.colors.system)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(4)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, LayoutConstants.horizontalPadding)
// Separator
// Note: Separator leading is matching the text leading, we could use alignment guide in the future
SeparatorLine()
.padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) {
HStack(spacing: LayoutConstants.avatarRightMargin) {
if isEditModeEnabled {
Image(viewData.isSelected ? Asset.Images.userSessionListItemSelected.name : Asset.Images.userSessionListItemNotSelected.name)
}
DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: viewData.isSelected)
VStack(alignment: .leading, spacing: 2) {
Text(viewData.sessionName)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.leading)
HStack {
if let sessionDetailsIcon = viewData.sessionDetailsIcon {
Image(sessionDetailsIcon)
.padding(.leading, 2)
}
Text(viewData.sessionDetails)
.font(theme.fonts.caption1)
.foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent)
.multilineTextAlignment(.leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, LayoutConstants.horizontalPadding)
// Separator
// Note: Separator leading is matching the text leading, we could use alignment guide in the future
SeparatorLine()
.padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
}
.padding(.top, LayoutConstants.verticalPadding)
}.onTapGesture {
onBackgroundTap?(viewData.sessionId)
}
.onLongPressGesture {
onBackgroundLongPress?(viewData.sessionId)
}
.padding(.top, LayoutConstants.verticalPadding)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("UserSessionListItem_\(viewData.sessionId)")
}
}
struct UserSessionListPreview: View {
let userSessionsOverviewService: UserSessionsOverviewServiceProtocol = MockUserSessionsOverviewService()
var isEditModeEnabled = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewData(session: userSessionInfo)
UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in
ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo)
UserSessionListItem(viewData: viewData, isEditModeEnabled: isEditModeEnabled, onBackgroundTap: { _ in
})
}
}
@@ -84,6 +106,8 @@ struct UserSessionListItem_Previews: PreviewProvider {
Group {
UserSessionListPreview().theme(.light).preferredColorScheme(.light)
UserSessionListPreview().theme(.dark).preferredColorScheme(.dark)
UserSessionListPreview(isEditModeEnabled: true).theme(.light).preferredColorScheme(.light)
UserSessionListPreview(isEditModeEnabled: true).theme(.dark).preferredColorScheme(.dark)
}
}
}
@@ -16,60 +16,25 @@
import Foundation
typealias SessionId = String
/// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable {
struct UserSessionListItemViewData: Identifiable, Hashable {
var id: String {
sessionId
}
let sessionId: String
let sessionId: SessionId
let sessionName: String
let sessionDetails: String
let highlightSessionDetails: Bool
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?
let isSelected: Bool
}
@@ -0,0 +1,95 @@
//
// 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 sessionInfo: UserSessionInfo,
highlightSessionDetails: Bool = false,
isSelected: Bool = false) -> UserSessionListItemViewData {
let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType,
sessionDisplayName: sessionInfo.name)
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
verificationState: sessionInfo.verificationState)
return UserSessionListItemViewData(sessionId: sessionInfo.id,
sessionName: sessionName,
sessionDetails: sessionDetails,
highlightSessionDetails: highlightSessionDetails,
deviceAvatarViewData: deviceAvatarViewData,
sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive),
isSelected: isSelected)
}
private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String {
if sessionInfo.isActive {
return activeSessionDetails(sessionInfo: sessionInfo)
} else {
return inactiveSessionDetails(sessionInfo: sessionInfo)
}
}
private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String {
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString)
}
return VectorL10n.userInactiveSessionItem
}
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
// Start by creating the main part of the details string.
var sessionDetailsString = ""
var lastActivityDateString: String?
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
}
if sessionInfo.isCurrent {
sessionDetailsString = VectorL10n.userOtherSessionCurrentSessionDetails
} else if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
sessionDetailsString = VectorL10n.userSessionItemDetailsLastActivity(lastActivityDateString)
}
// Prepend the verification state if one is known.
let sessionStatusText: String?
switch sessionInfo.verificationState {
case .verified:
sessionStatusText = VectorL10n.userSessionVerifiedShort
case .unverified:
sessionStatusText = VectorL10n.userSessionUnverifiedShort
case .unknown:
sessionStatusText = nil
}
if let sessionStatusText = sessionStatusText {
if sessionDetailsString.isEmpty {
sessionDetailsString = sessionStatusText
} else {
sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, sessionDetailsString)
}
} else if sessionDetailsString.isEmpty {
sessionDetailsString = VectorL10n.userSessionVerificationUnknownShort
}
return sessionDetailsString
}
private func getSessionDetailsIcon(isActive: Bool) -> String? {
isActive ? nil : Asset.Images.userSessionListItemInactiveSession.name
}
}
@@ -0,0 +1,65 @@
//
// 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 UserSessionsListViewAllView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let count: Int
var onBackgroundTap: (() -> Void)?
var body: some View {
Button {
onBackgroundTap?()
} label: {
Button(action: { onBackgroundTap?() }) {
VStack(spacing: 0) {
HStack {
Text("View all (\(count))")
.font(theme.fonts.body)
.foregroundColor(theme.colors.accent)
.frame(maxWidth: .infinity, alignment: .leading)
Image(Asset.Images.chevron.name)
}
.padding(.vertical, 15)
.padding(.trailing, 20)
SeparatorLine()
}
.background(theme.colors.background)
.padding(.leading, 72)
}
}
.accessibilityIdentifier("ViewAllButton")
}
}
struct UserSessionsListViewAllView_Previews: PreviewProvider {
static var previews: some View {
Group {
UserSessionsListViewAllView(count: 8)
.previewLayout(PreviewLayout.sizeThatFits)
.theme(.light)
.preferredColorScheme(.light)
UserSessionsListViewAllView(count: 8)
.previewLayout(PreviewLayout.sizeThatFits)
.theme(.dark)
.preferredColorScheme(.dark)
}
}
}
@@ -21,22 +21,36 @@ struct UserSessionsOverview: View {
@ObservedObject var viewModel: UserSessionsOverviewViewModel.Context
private let maxOtherSessionsToDisplay = 5
var body: some View {
ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
GeometryReader { _ in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
}
}
.readableFrame()
// if viewModel.viewState.linkDeviceButtonVisible {
// linkDeviceView
// .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
// }
}
}
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.navigationTitle(VectorL10n.userSessionsOverviewTitle)
.navigationBarTitleDisplayMode(.inline)
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
.accentColor(theme.colors.accent)
.onAppear {
viewModel.send(viewAction: .viewAppeared)
}
@@ -91,26 +105,61 @@ struct UserSessionsOverview: View {
viewModel.send(viewAction: .viewCurrentSessionDetails)
})
} header: {
Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 12.0)
.padding(.top, 24.0)
HStack(alignment: .firstTextBaseline) {
Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 12.0)
.padding(.top, 24.0)
currentSessionMenu
}
}
.padding(.horizontal, 16)
}
}
private var currentSessionMenu: some View {
Menu {
SwiftUI.Section {
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
}
}
if #available(iOS 15, *) {
Button(role: .destructive) { viewModel.send(viewAction: .logoutOfCurrentSession) } label: {
Label(VectorL10n.signOut, systemImage: "rectangle.portrait.and.arrow.right.fill")
}
} else {
Button { viewModel.send(viewAction: .logoutOfCurrentSession) } label: {
Label(VectorL10n.signOut, systemImage: "rectangle.righthalf.inset.fill.arrow.right")
}
}
} label: {
Image(systemName: "ellipsis")
.foregroundColor(theme.colors.secondaryContent)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
.offset(x: 8) // Re-align the symbol after applying padding.
}
private var otherSessionsSection: some View {
SwiftUI.Section {
LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.otherSessionsViewData) { viewData in
ForEach(viewModel.viewState.otherSessionsViewData.prefix(maxOtherSessionsToDisplay)) { viewData in
UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
viewModel.send(viewAction: .tapUserSession(sessionId))
})
}
if viewModel.viewState.otherSessionsViewData.count > maxOtherSessionsToDisplay {
UserSessionsListViewAllView(count: viewModel.viewState.otherSessionsViewData.count) {
viewModel.send(viewAction: .viewAllOtherSessions)
}
}
}
.background(theme.colors.background)
} header: {
@@ -132,6 +181,23 @@ struct UserSessionsOverview: View {
}
.accessibilityIdentifier("userSessionsOverviewOtherSection")
}
/// The footer view containing link device button.
var linkDeviceView: some View {
VStack {
Button {
viewModel.send(viewAction: .linkDevice)
} label: {
Text(VectorL10n.userSessionsOverviewLinkDevice)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.padding(.top, 28)
.padding(.bottom, 12)
.padding(.horizontal, 16)
.accessibilityIdentifier("linkDeviceButton")
}
.background(theme.colors.system.ignoresSafeArea())
}
}
// MARK: - Previews