mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-04 15:07:43 +02:00
Release 2.0.0
This commit is contained in:
+19
-21
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
}
|
||||
|
||||
+63
-29
@@ -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"],
|
||||
|
||||
+39
-33
@@ -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",
|
||||
|
||||
+17
-2
@@ -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 }
|
||||
}
|
||||
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -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.")
|
||||
}
|
||||
}
|
||||
+15
-10
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -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
|
||||
}
|
||||
|
||||
+40
-19
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+55
-31
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-45
@@ -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
|
||||
}
|
||||
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user