mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-23 16:06:38 +02:00
Merge branch 'develop' of github.com:vector-im/element-ios into langleyd/6830_wysiwyg_core_formatting
This commit is contained in:
+2
@@ -34,6 +34,8 @@ protocol AuthenticationRestClient: AnyObject {
|
||||
func login(parameters: LoginParameters) async throws -> MXCredentials
|
||||
func login(parameters: [String: Any]) async throws -> MXCredentials
|
||||
|
||||
func generateLoginToken() async throws -> MXLoginToken
|
||||
|
||||
// MARK: Registration
|
||||
|
||||
var registerFallbackURL: URL { get }
|
||||
|
||||
@@ -19,6 +19,7 @@ import Foundation
|
||||
/// The static list of mocked screens in RiotSwiftUI
|
||||
enum MockAppScreens {
|
||||
static let appScreens: [MockScreenState.Type] = [
|
||||
MockUserSessionNameScreenState.self,
|
||||
MockUserOtherSessionsScreenState.self,
|
||||
MockUserSessionsOverviewScreenState.self,
|
||||
MockUserSessionDetailsScreenState.self,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// 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 Introspect
|
||||
import SwiftUI
|
||||
|
||||
/// Introspects the view to find a table view on iOS 14/15 or a collection view
|
||||
/// on iOS 16 and sets the background to the specified color.
|
||||
struct ListBackgroundModifier: ViewModifier {
|
||||
/// The background color.
|
||||
let color: Color
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// When using Xcode 13
|
||||
#if compiler(<5.7)
|
||||
// SwiftUI's List is backed by a table view.
|
||||
content.introspectTableView { $0.backgroundColor = UIColor(color) }
|
||||
|
||||
// When using Xcode 14+
|
||||
#else
|
||||
if #available(iOS 16, *) {
|
||||
// SwiftUI's List is backed by a collection view on iOS 16.
|
||||
content
|
||||
.introspectCollectionView { $0.backgroundColor = UIColor(color) }
|
||||
.scrollContentBackground(.hidden)
|
||||
} else {
|
||||
// SwiftUI's List is backed by a table view on iOS 15 and below.
|
||||
content.introspectTableView { $0.backgroundColor = UIColor(color) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Sets the background color of a `List` using introspection.
|
||||
func listBackgroundColor(_ color: Color) -> some View {
|
||||
modifier(ListBackgroundModifier(color: color))
|
||||
}
|
||||
|
||||
/// Finds a `UICollectionView` from a `SwiftUI.List`, or `SwiftUI.List` child.
|
||||
/// Stop gap until https://github.com/siteline/SwiftUI-Introspect/pull/169
|
||||
func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View {
|
||||
introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: customize)
|
||||
}
|
||||
}
|
||||
+6
-24
@@ -103,8 +103,10 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
|
||||
self.completion?()
|
||||
case .share(let latitude, let longitude, let coordinateType):
|
||||
self.shareStaticLocation(latitude: latitude, longitude: longitude, coordinateType: coordinateType)
|
||||
self.completion?()
|
||||
case .shareLiveLocation(let timeout):
|
||||
self.startLiveLocationSharing(with: timeout)
|
||||
self.completion?()
|
||||
case .checkLiveLocationCanBeStarted(let completion):
|
||||
self.checkLiveLocationCanBeStarted(completion: completion)
|
||||
}
|
||||
@@ -125,43 +127,23 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
private func shareStaticLocation(latitude: Double, longitude: Double, coordinateType: LocationSharingCoordinateType) {
|
||||
locationSharingViewModel.startLoading()
|
||||
|
||||
parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil, coordinateType: coordinateType.eventAssetType()) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.locationSharingViewModel.stopLoading()
|
||||
self.completion?()
|
||||
} failure: { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil, coordinateType: coordinateType.eventAssetType()) { _ in
|
||||
} failure: { error in
|
||||
MXLog.error("[LocationSharingCoordinator] Failed sharing location", context: error)
|
||||
self.locationSharingViewModel.stopLoading(error: .locationSharingError)
|
||||
}
|
||||
}
|
||||
|
||||
private func startLiveLocationSharing(with timeout: TimeInterval) {
|
||||
guard let locationService = parameters.roomDataSource.mxSession.locationService, let roomId = parameters.roomDataSource.roomId else {
|
||||
locationSharingViewModel.stopLoading(error: .locationSharingError)
|
||||
return
|
||||
}
|
||||
|
||||
locationService.startUserLocationSharing(withRoomId: roomId, description: nil, timeout: timeout) { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
|
||||
locationService.startUserLocationSharing(withRoomId: roomId, description: nil, timeout: timeout) { response in
|
||||
switch response {
|
||||
case .success:
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.locationSharingViewModel.stopLoading()
|
||||
self.completion?()
|
||||
}
|
||||
break
|
||||
case .failure(let error):
|
||||
MXLog.error("[LocationSharingCoordinator] Failed to start live location sharing", context: error)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.locationSharingViewModel.stopLoading(error: .locationSharingError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +132,9 @@ enum UserAgentParser {
|
||||
if deviceInfoComponents[safe: 1]?.hasPrefix("Android") == true {
|
||||
deviceOS = deviceInfoComponents[safe: 1]
|
||||
} else if deviceInfoComponents.first == "Macintosh" {
|
||||
var osFull = deviceInfoComponents[safe: 1]
|
||||
osFull = osFull?.replacingOccurrences(of: "Intel ", with: "")
|
||||
osFull = osFull?.replacingOccurrences(of: "Mac OS X", with: "macOS")
|
||||
osFull = osFull?.replacingOccurrences(of: "_", with: ".")
|
||||
deviceOS = osFull
|
||||
deviceOS = "macOS"
|
||||
} else if deviceInfoComponents.first?.hasPrefix("Windows") == true {
|
||||
deviceOS = "Windows"
|
||||
} else {
|
||||
deviceOS = deviceInfoComponents.first
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ struct UserSessionInfo: Identifiable {
|
||||
/// The device type used by the session
|
||||
let deviceType: DeviceType
|
||||
|
||||
/// True to indicate that the session is verified
|
||||
let isVerified: Bool
|
||||
/// The current state of verification for the session.
|
||||
let verificationState: VerificationState
|
||||
|
||||
/// The IP address where this device was last seen.
|
||||
let lastSeenIP: String?
|
||||
@@ -69,10 +69,84 @@ struct UserSessionInfo: Identifiable {
|
||||
|
||||
/// True to indicate that this is current user session
|
||||
let isCurrent: Bool
|
||||
|
||||
/// Represents a verification state.
|
||||
enum VerificationState {
|
||||
/// The state is unknown (likely because the current session
|
||||
/// hasn't been set up for cross-signing yet).
|
||||
case unknown
|
||||
/// The session has not yet been verified.
|
||||
case unverified
|
||||
/// The session has been verified.
|
||||
case verified
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
extension UserSessionInfo: Equatable {
|
||||
static func == (lhs: UserSessionInfo, rhs: UserSessionInfo) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
extension UserSessionInfo {
|
||||
static var mockPhone: UserSessionInfo {
|
||||
UserSessionInfo(id: "1",
|
||||
name: "Element Mobile: iOS",
|
||||
deviceType: .mobile,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.9.8",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 16.0.2",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false)
|
||||
}
|
||||
|
||||
static var mockPhoneUnverified: UserSessionInfo {
|
||||
UserSessionInfo(id: "1",
|
||||
name: "Element Mobile: iOS",
|
||||
deviceType: .mobile,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.9.8",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 16.0.2",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false)
|
||||
}
|
||||
|
||||
static var mockPhoneUnknownVerification: UserSessionInfo {
|
||||
UserSessionInfo(id: "1",
|
||||
name: "Element Mobile: iOS",
|
||||
deviceType: .mobile,
|
||||
verificationState: .unknown,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.9.8",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 16.0.2",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,12 @@ struct DeviceAvatarView: View {
|
||||
.clipShape(Circle())
|
||||
|
||||
// Verification badge
|
||||
if let isVerified = viewData.isVerified {
|
||||
Image(isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name)
|
||||
.frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize))
|
||||
.shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle())
|
||||
.background(theme.colors.background)
|
||||
.clipShape(Circle())
|
||||
.offset(x: 10, y: 8)
|
||||
}
|
||||
Image(viewData.verificationImageName)
|
||||
.frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize))
|
||||
.shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle())
|
||||
.background(theme.colors.background)
|
||||
.clipShape(Circle())
|
||||
.offset(x: 10, y: 8)
|
||||
}
|
||||
.frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize))
|
||||
}
|
||||
@@ -54,20 +52,20 @@ struct DeviceAvatarView: View {
|
||||
struct DeviceAvatarViewListPreview: View {
|
||||
var viewDataList: [DeviceAvatarViewData] {
|
||||
[
|
||||
DeviceAvatarViewData(deviceType: .desktop, isVerified: true),
|
||||
DeviceAvatarViewData(deviceType: .web, isVerified: true),
|
||||
DeviceAvatarViewData(deviceType: .mobile, isVerified: true),
|
||||
DeviceAvatarViewData(deviceType: .unknown, isVerified: true)
|
||||
DeviceAvatarViewData(deviceType: .desktop, verificationState: .verified),
|
||||
DeviceAvatarViewData(deviceType: .web, verificationState: .verified),
|
||||
DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified),
|
||||
DeviceAvatarViewData(deviceType: .unknown, verificationState: .verified)
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, isVerified: true))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, isVerified: false))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, isVerified: true))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, isVerified: false))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .web, verificationState: .verified))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, verificationState: .unverified))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, verificationState: .verified))
|
||||
DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, verificationState: .unverified))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,18 @@ import SwiftUI
|
||||
/// View data for DeviceAvatarView
|
||||
struct DeviceAvatarViewData: Hashable {
|
||||
let deviceType: DeviceType
|
||||
let isVerified: Bool?
|
||||
/// The current state of verification for the session.
|
||||
let verificationState: UserSessionInfo.VerificationState
|
||||
|
||||
/// The name of the shield image to show for the device.
|
||||
var verificationImageName: String {
|
||||
switch verificationState {
|
||||
case .verified:
|
||||
return Asset.Images.userSessionVerified.name
|
||||
case .unverified:
|
||||
return Asset.Images.userSessionUnverified.name
|
||||
case .unknown:
|
||||
return Asset.Images.userSessionVerificationUnknown.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,22 +26,6 @@ struct UserSessionCardView: View {
|
||||
var onViewDetailsAction: ((String) -> Void)?
|
||||
var onLearnMoreAction: (() -> Void)?
|
||||
|
||||
private var verificationStatusImageName: String {
|
||||
viewData.isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name
|
||||
}
|
||||
|
||||
private var verificationStatusText: String {
|
||||
viewData.isVerified ? VectorL10n.userSessionVerified : VectorL10n.userSessionUnverified
|
||||
}
|
||||
|
||||
private var verificationStatusColor: Color {
|
||||
viewData.isVerified ? theme.colors.accent : theme.colors.alert
|
||||
}
|
||||
|
||||
private var verificationStatusAdditionalInfoText: String {
|
||||
viewData.isVerified ? VectorL10n.userSessionVerifiedAdditionalInfo : VectorL10n.userSessionUnverifiedAdditionalInfo
|
||||
}
|
||||
|
||||
private var backgroundShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
}
|
||||
@@ -53,27 +37,25 @@ struct UserSessionCardView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
DeviceAvatarView(viewData: viewData.deviceAvatarViewData)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(viewData.sessionName)
|
||||
.font(theme.fonts.headline)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack {
|
||||
Image(verificationStatusImageName)
|
||||
Text(verificationStatusText)
|
||||
.font(theme.fonts.subheadline)
|
||||
.foregroundColor(verificationStatusColor)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
Label(viewData.verificationStatusText, image: viewData.verificationStatusImageName)
|
||||
.font(theme.fonts.subheadline)
|
||||
.foregroundColor(theme.colors[keyPath: viewData.verificationStatusColor])
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if viewData.isCurrentSessionDisplayMode {
|
||||
Text(verificationStatusAdditionalInfoText)
|
||||
Text(viewData.verificationStatusAdditionalInfoText)
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
InlineTextButton(verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) {
|
||||
InlineTextButton(viewData.verificationStatusAdditionalInfoText + " %@", tappableText: VectorL10n.userSessionLearnMore) {
|
||||
onLearnMoreAction?()
|
||||
}
|
||||
.font(theme.fonts.footnote)
|
||||
@@ -99,7 +81,7 @@ struct UserSessionCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if viewData.isVerified == false {
|
||||
if viewData.verificationState != .verified {
|
||||
Button {
|
||||
onVerifyAction?(viewData.sessionId)
|
||||
} label: {
|
||||
@@ -137,11 +119,11 @@ struct UserSessionCardViewPreview: View {
|
||||
|
||||
let viewData: UserSessionCardViewData
|
||||
|
||||
init(isCurrent: Bool = false) {
|
||||
init(isCurrent: Bool = false, verificationState: UserSessionInfo.VerificationState = .unverified) {
|
||||
let sessionInfo = UserSessionInfo(id: "alice",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: verificationState,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
@@ -174,6 +156,13 @@ struct UserSessionCardView_Previews: PreviewProvider {
|
||||
UserSessionCardViewPreview(isCurrent: true).theme(.dark).preferredColorScheme(.dark)
|
||||
UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light)
|
||||
UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark)
|
||||
|
||||
UserSessionCardViewPreview(isCurrent: true, verificationState: .verified)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
UserSessionCardViewPreview(verificationState: .verified)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
UserSessionCardViewPreview(verificationState: .unknown)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DesignKit
|
||||
import SwiftUI
|
||||
|
||||
/// View data for UserSessionCardView
|
||||
struct UserSessionCardViewData {
|
||||
@@ -26,7 +27,8 @@ struct UserSessionCardViewData {
|
||||
|
||||
let sessionName: String
|
||||
|
||||
let isVerified: Bool
|
||||
/// The verification state used to render the card with.
|
||||
let verificationState: UserSessionInfo.VerificationState
|
||||
|
||||
let lastActivityDateString: String?
|
||||
|
||||
@@ -37,16 +39,64 @@ struct UserSessionCardViewData {
|
||||
/// Indicate if the current user session is shown and to adpat the layout
|
||||
let isCurrentSessionDisplayMode: Bool
|
||||
|
||||
/// The name of the shield image to show the verification status.
|
||||
var verificationStatusImageName: String {
|
||||
switch verificationState {
|
||||
case .verified:
|
||||
return Asset.Images.userSessionVerified.name
|
||||
case .unverified:
|
||||
return Asset.Images.userSessionUnverified.name
|
||||
case .unknown:
|
||||
return Asset.Images.userSessionVerificationUnknown.name
|
||||
}
|
||||
}
|
||||
|
||||
/// The text to show alongside the verification shield image.
|
||||
var verificationStatusText: String {
|
||||
switch verificationState {
|
||||
case .verified:
|
||||
return VectorL10n.userSessionVerified
|
||||
case .unverified:
|
||||
return VectorL10n.userSessionUnverified
|
||||
case .unknown:
|
||||
return VectorL10n.userSessionVerificationUnknown
|
||||
}
|
||||
}
|
||||
|
||||
/// A key path to the theme colour to use for the verification status text.
|
||||
var verificationStatusColor: KeyPath<ColorSwiftUI, Color> {
|
||||
switch verificationState {
|
||||
case .verified:
|
||||
return \.accent
|
||||
case .unverified:
|
||||
return \.alert
|
||||
case .unknown:
|
||||
return \.secondaryContent
|
||||
}
|
||||
}
|
||||
|
||||
/// Further information to be shown to explain the verification state to the user.
|
||||
var verificationStatusAdditionalInfoText: String {
|
||||
switch verificationState {
|
||||
case .verified:
|
||||
return VectorL10n.userSessionVerifiedAdditionalInfo
|
||||
case .unverified:
|
||||
return VectorL10n.userSessionUnverifiedAdditionalInfo
|
||||
case .unknown:
|
||||
return VectorL10n.userSessionUnverifiedAdditionalInfo
|
||||
}
|
||||
}
|
||||
|
||||
init(sessionId: String,
|
||||
sessionDisplayName: String?,
|
||||
deviceType: DeviceType,
|
||||
isVerified: Bool,
|
||||
verificationState: UserSessionInfo.VerificationState,
|
||||
lastActivityTimestamp: TimeInterval?,
|
||||
lastSeenIP: String?,
|
||||
isCurrentSessionDisplayMode: Bool = false) {
|
||||
self.sessionId = sessionId
|
||||
sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName)
|
||||
self.isVerified = isVerified
|
||||
self.verificationState = verificationState
|
||||
|
||||
var lastActivityDateString: String?
|
||||
|
||||
@@ -56,7 +106,7 @@ struct UserSessionCardViewData {
|
||||
|
||||
self.lastActivityDateString = lastActivityDateString
|
||||
lastSeenIPInfo = lastSeenIP
|
||||
deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: nil)
|
||||
deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, verificationState: verificationState)
|
||||
|
||||
self.isCurrentSessionDisplayMode = isCurrentSessionDisplayMode
|
||||
}
|
||||
@@ -67,7 +117,7 @@ extension UserSessionCardViewData {
|
||||
self.init(sessionId: sessionInfo.id,
|
||||
sessionDisplayName: sessionInfo.name,
|
||||
deviceType: sessionInfo.deviceType,
|
||||
isVerified: sessionInfo.isVerified,
|
||||
verificationState: sessionInfo.verificationState,
|
||||
lastActivityTimestamp: sessionInfo.lastSeenTimestamp,
|
||||
lastSeenIP: sessionInfo.lastSeenIP,
|
||||
isCurrentSessionDisplayMode: sessionInfo.isCurrent)
|
||||
|
||||
@@ -24,6 +24,7 @@ struct UserSessionsFlowCoordinatorParameters {
|
||||
|
||||
final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
private let parameters: UserSessionsFlowCoordinatorParameters
|
||||
private let allSessionsService: UserSessionsOverviewService
|
||||
|
||||
private let navigationRouter: NavigationRouterType
|
||||
private var reauthenticationPresenter: ReauthenticationCoordinatorBridgePresenter?
|
||||
@@ -41,6 +42,9 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
init(parameters: UserSessionsFlowCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let dataProvider = UserSessionsDataProvider(session: parameters.session)
|
||||
allSessionsService = UserSessionsOverviewService(dataProvider: dataProvider)
|
||||
|
||||
navigationRouter = parameters.router
|
||||
errorPresenter = MXKErrorAlertPresentation()
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable())
|
||||
@@ -59,22 +63,23 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
private func createUserSessionsOverviewCoordinator() -> UserSessionsOverviewCoordinator {
|
||||
let parameters = UserSessionsOverviewCoordinatorParameters(session: parameters.session)
|
||||
let parameters = UserSessionsOverviewCoordinatorParameters(session: parameters.session,
|
||||
service: allSessionsService)
|
||||
|
||||
let coordinator = UserSessionsOverviewCoordinator(parameters: parameters)
|
||||
coordinator.completion = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .verifyCurrentSession:
|
||||
self.showCompleteSecurity()
|
||||
case let .renameSession(sessionInfo):
|
||||
break
|
||||
self.showRenameSessionScreen(for: sessionInfo)
|
||||
case let .logoutOfSession(sessionInfo):
|
||||
self.showLogoutConfirmation(for: sessionInfo)
|
||||
case let .openSessionOverview(sessionInfo: sessionInfo):
|
||||
self.openSessionOverview(sessionInfo: sessionInfo)
|
||||
case let .openOtherSessions(sessionInfos: sessionInfos, filter: filter):
|
||||
self.openOtherSessions(sessionInfos: sessionInfos,
|
||||
filterBy: filter,
|
||||
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
|
||||
self.openOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
|
||||
case .linkDevice:
|
||||
self.openQRLoginScreen()
|
||||
}
|
||||
@@ -99,8 +104,14 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
switch result {
|
||||
case let .openSessionDetails(sessionInfo: sessionInfo):
|
||||
self.openSessionDetails(sessionInfo: sessionInfo)
|
||||
case let .verifySession(sessionInfo):
|
||||
if sessionInfo.isCurrent {
|
||||
self.showCompleteSecurity()
|
||||
} else {
|
||||
self.showVerification(for: sessionInfo)
|
||||
}
|
||||
case let .renameSession(sessionInfo):
|
||||
break
|
||||
self.showRenameSessionScreen(for: sessionInfo)
|
||||
case let .logoutOfSession(sessionInfo):
|
||||
self.showLogoutConfirmation(for: sessionInfo)
|
||||
}
|
||||
@@ -125,11 +136,13 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
|
||||
private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator {
|
||||
let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session,
|
||||
sessionInfo: sessionInfo)
|
||||
sessionInfo: sessionInfo,
|
||||
sessionsOverviewDataPublisher: allSessionsService.overviewDataPublisher)
|
||||
return UserSessionOverviewCoordinator(parameters: parameters)
|
||||
}
|
||||
|
||||
private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
|
||||
private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
|
||||
let title = filter == .all ? VectorL10n.userSessionsOverviewOtherSessionsSectionTitle : VectorL10n.userOtherSessionSecurityRecommendationTitle
|
||||
let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos,
|
||||
filterBy: filter,
|
||||
title: title)
|
||||
@@ -204,7 +217,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
self.stopLoading()
|
||||
|
||||
guard response.isSuccess else {
|
||||
MXLog.debug("[LogoutDeviceService] Delete device (\(sessionInfo.id) failed")
|
||||
MXLog.debug("[UserSessionsFlowCoordinator] Delete device (\(sessionInfo.id)) failed")
|
||||
if let error = response.error {
|
||||
self.errorPresenter.presentError(from: self.toPresentable(), forError: error, animated: true, handler: { })
|
||||
} else {
|
||||
@@ -218,6 +231,69 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
private func showRenameSessionScreen(for sessionInfo: UserSessionInfo) {
|
||||
let parameters = UserSessionNameCoordinatorParameters(session: parameters.session, sessionInfo: sessionInfo)
|
||||
let coordinator = UserSessionNameCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.completion = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
switch result {
|
||||
case .sessionNameUpdated:
|
||||
self.allSessionsService.updateOverviewData { [weak self] _ in
|
||||
self?.navigationRouter.dismissModule(animated: true, completion: nil)
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
case .cancel:
|
||||
self.navigationRouter.dismissModule(animated: true, completion: nil)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
let modalRouter = NavigationRouter(navigationController: RiotNavigationController())
|
||||
modalRouter.setRootModule(coordinator)
|
||||
coordinator.start()
|
||||
|
||||
navigationRouter.present(modalRouter, animated: true)
|
||||
}
|
||||
|
||||
/// Shows a prompt to the user that it is not possible to verify
|
||||
/// another session until the current session has been verified.
|
||||
private func showCannotVerifyOtherSessionPrompt() {
|
||||
let alert = UIAlertController(title: VectorL10n.securitySettingsCompleteSecurityAlertTitle,
|
||||
message: VectorL10n.securitySettingsCompleteSecurityAlertMessage,
|
||||
preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: VectorL10n.later, style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: VectorL10n.ok, style: .default) { [weak self] _ in
|
||||
self?.showCompleteSecurity()
|
||||
})
|
||||
|
||||
navigationRouter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// Shows the Complete Security modal for the user to verify their current session.
|
||||
private func showCompleteSecurity() {
|
||||
AppDelegate.theDelegate().presentCompleteSecurity(for: parameters.session)
|
||||
}
|
||||
|
||||
/// Shows the verification screen for the specified session.
|
||||
private func showVerification(for sessionInfo: UserSessionInfo) {
|
||||
if sessionInfo.verificationState == .unknown {
|
||||
showCannotVerifyOtherSessionPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
let coordinator = UserVerificationCoordinator(presenter: toPresentable(),
|
||||
session: parameters.session,
|
||||
userId: parameters.session.myUserId,
|
||||
userDisplayName: nil,
|
||||
deviceId: sessionInfo.id)
|
||||
coordinator.delegate = self
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
/// Pops back to the root coordinator in the session management flow.
|
||||
private func popToSessionsOverview() {
|
||||
guard let sessionsOverviewCoordinator = sessionsOverviewCoordinator else { return }
|
||||
@@ -263,3 +339,30 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
navigationRouter.toPresentable()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CrossSigningSetupCoordinatorDelegate
|
||||
|
||||
extension UserSessionsFlowCoordinator: CrossSigningSetupCoordinatorDelegate {
|
||||
func crossSigningSetupCoordinatorDidComplete(_ coordinator: CrossSigningSetupCoordinatorType) {
|
||||
// The service is listening for changes so there's nothing to do here.
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func crossSigningSetupCoordinatorDidCancel(_ coordinator: CrossSigningSetupCoordinatorType) {
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func crossSigningSetupCoordinator(_ coordinator: CrossSigningSetupCoordinatorType, didFailWithError error: Error) {
|
||||
remove(childCoordinator: coordinator)
|
||||
errorPresenter.presentError(from: toPresentable(), forError: error, animated: true, handler: { })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UserVerificationCoordinatorDelegate
|
||||
|
||||
extension UserSessionsFlowCoordinator: UserVerificationCoordinatorDelegate {
|
||||
func userVerificationCoordinatorDidComplete(_ coordinator: UserVerificationCoordinatorType) {
|
||||
// The service is listening for changes so there's nothing to do here.
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-9
@@ -23,7 +23,8 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
|
||||
|
||||
case all
|
||||
case inactiveSessions
|
||||
case unverifiedSessions
|
||||
|
||||
@@ -35,13 +36,17 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockUserOtherSessionsScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.inactiveSessions, .unverifiedSessions]
|
||||
[.all, .inactiveSessions, .unverifiedSessions]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: UserOtherSessionsViewModel
|
||||
switch self {
|
||||
case .all:
|
||||
viewModel = UserOtherSessionsViewModel(sessionInfos: allSessions(),
|
||||
filter: .all,
|
||||
title: VectorL10n.userSessionsOverviewOtherSessionsSectionTitle)
|
||||
case .inactiveSessions:
|
||||
viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(),
|
||||
filter: .inactive,
|
||||
@@ -51,7 +56,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
filter: .unverified,
|
||||
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
|
||||
}
|
||||
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
@@ -64,7 +69,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
[UserSessionInfo(id: "0",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: nil,
|
||||
@@ -80,7 +85,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
isVerified: true,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
|
||||
applicationName: nil,
|
||||
@@ -96,7 +101,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
UserSessionInfo(id: "2",
|
||||
name: "Firefox on Windows",
|
||||
deviceType: .web,
|
||||
isVerified: true,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "2.0.0.2",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000,
|
||||
applicationName: nil,
|
||||
@@ -112,7 +117,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
UserSessionInfo(id: "3",
|
||||
name: "Android",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "3.0.0.3",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000,
|
||||
applicationName: nil,
|
||||
@@ -131,7 +136,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
[UserSessionInfo(id: "0",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: nil,
|
||||
@@ -147,7 +152,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
|
||||
applicationName: nil,
|
||||
@@ -161,4 +166,103 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
isActive: true,
|
||||
isCurrent: false)]
|
||||
}
|
||||
|
||||
private func allSessions() -> [UserSessionInfo] {
|
||||
[UserSessionInfo(id: "0",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 500_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false),
|
||||
UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false),
|
||||
UserSessionInfo(id: "2",
|
||||
name: "Firefox on Windows",
|
||||
deviceType: .web,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "2.0.0.2",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false),
|
||||
UserSessionInfo(id: "3",
|
||||
name: "Android",
|
||||
deviceType: .mobile,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "3.0.0.3",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false),
|
||||
UserSessionInfo(id: "4",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 11_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false),
|
||||
UserSessionInfo(id: "5",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 20_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: false,
|
||||
isCurrent: false)]
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -43,4 +43,10 @@ class UserOtherSessionsUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertTrue(app.buttons["iOS, Unverified · Your current session"].exists)
|
||||
}
|
||||
|
||||
func test_whenOtherSessionsWithAllSessionFilterPresented_correctHeaderDisplayed() {
|
||||
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.all.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewOtherSessionsSectionInfo].exists)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -49,11 +49,26 @@ class UserOtherSessionsViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(sut.state, expectedState)
|
||||
}
|
||||
|
||||
func test_whenModelCreated_withAllFilter_viewStateIsCorrect() {
|
||||
let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
|
||||
let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos,
|
||||
filter: .all,
|
||||
title: "Title")
|
||||
|
||||
let expectedHeader = UserOtherSessionsHeaderViewData(title: nil,
|
||||
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
|
||||
iconName: nil)
|
||||
let expectedItems = sessionInfos.filter { !$0.isCurrent }.asViewData()
|
||||
let expectedState = UserOtherSessionsViewState(title: "Title",
|
||||
sections: [.sessionItems(header: expectedHeader, items: expectedItems)])
|
||||
XCTAssertEqual(sut.state, expectedState)
|
||||
}
|
||||
|
||||
private func createUserSessionInfo(sessionId: String) -> UserSessionInfo {
|
||||
UserSessionInfo(id: sessionId,
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
|
||||
applicationName: nil,
|
||||
|
||||
@@ -72,16 +72,15 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
|
||||
case .inactive:
|
||||
return sessionInfos.filter { !$0.isActive }
|
||||
case .unverified:
|
||||
return sessionInfos.filter { !$0.isVerified }
|
||||
return sessionInfos.filter { $0.verificationState != .verified }
|
||||
}
|
||||
}
|
||||
|
||||
private func createHeaderData(filter: OtherUserSessionsFilter) -> UserOtherSessionsHeaderViewData {
|
||||
switch filter {
|
||||
case .all:
|
||||
// TODO:
|
||||
return UserOtherSessionsHeaderViewData(title: nil,
|
||||
subtitle: "",
|
||||
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
|
||||
iconName: nil)
|
||||
case .inactive:
|
||||
return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
|
||||
|
||||
+27
-8
@@ -39,6 +39,7 @@ struct UserOtherSessionsHeaderView: View {
|
||||
.background(theme.colors.background)
|
||||
.clipShape(backgroundShape)
|
||||
.shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0, content: {
|
||||
if let title = viewData.title {
|
||||
@@ -52,7 +53,6 @@ struct UserOtherSessionsHeaderView: View {
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.bottom, 20.0)
|
||||
})
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
@@ -62,18 +62,37 @@ struct UserOtherSessionsHeaderView: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct UserOtherSessionsHeaderView_Previews: PreviewProvider {
|
||||
private static let headerWithTitleSubtitleIcon = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
|
||||
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
|
||||
iconName: Asset.Images.userOtherSessionsInactive.name)
|
||||
|
||||
private static let headerWithSubtitle = UserOtherSessionsHeaderViewData(title: nil,
|
||||
subtitle: VectorL10n.userSessionsOverviewOtherSessionsSectionInfo,
|
||||
iconName: nil)
|
||||
|
||||
private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
|
||||
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
|
||||
iconName: Asset.Images.userOtherSessionsInactive.name)
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData)
|
||||
.theme(.light)
|
||||
.preferredColorScheme(.light)
|
||||
UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData)
|
||||
.theme(.dark)
|
||||
.preferredColorScheme(.dark)
|
||||
VStack {
|
||||
Divider()
|
||||
UserOtherSessionsHeaderView(viewData: self.headerWithTitleSubtitleIcon)
|
||||
Divider()
|
||||
UserOtherSessionsHeaderView(viewData: self.headerWithSubtitle)
|
||||
Divider()
|
||||
}
|
||||
.theme(.light)
|
||||
.preferredColorScheme(.light)
|
||||
VStack {
|
||||
Divider()
|
||||
UserOtherSessionsHeaderView(viewData: self.headerWithTitleSubtitleIcon)
|
||||
Divider()
|
||||
UserOtherSessionsHeaderView(viewData: self.headerWithSubtitle)
|
||||
Divider()
|
||||
}
|
||||
.theme(.dark)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
|
||||
sessionInfo = UserSessionInfo(id: "alice",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
@@ -61,7 +61,7 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
|
||||
sessionInfo = UserSessionInfo(id: "3",
|
||||
name: "Android",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "3.0.0.3",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
|
||||
applicationName: "Element Android",
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ class UserSessionDetailsViewModelTests: XCTestCase {
|
||||
UserSessionInfo(id: id,
|
||||
name: name,
|
||||
deviceType: deviceType,
|
||||
isVerified: isVerified,
|
||||
verificationState: isVerified ? .verified : .unverified,
|
||||
lastSeenIP: lastSeenIP,
|
||||
lastSeenTimestamp: lastSeenTimestamp,
|
||||
applicationName: applicationName,
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct UserSessionNameCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let sessionInfo: UserSessionInfo
|
||||
}
|
||||
|
||||
final class UserSessionNameCoordinator: Coordinator, Presentable {
|
||||
private let parameters: UserSessionNameCoordinatorParameters
|
||||
private let userSessionNameHostingController: UIViewController
|
||||
private var userSessionNameViewModel: UserSessionNameViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: ((UserSessionNameCoordinatorResult) -> Void)?
|
||||
|
||||
init(parameters: UserSessionNameCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = UserSessionNameViewModel(sessionInfo: parameters.sessionInfo)
|
||||
let view = UserSessionName(viewModel: viewModel.context)
|
||||
userSessionNameViewModel = viewModel
|
||||
userSessionNameHostingController = VectorHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userSessionNameHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[UserSessionNameCoordinator] did start.")
|
||||
userSessionNameViewModel.completion = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
MXLog.debug("[UserSessionNameCoordinator] UserSessionNameViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .updateName(let newName):
|
||||
self.updateName(newName)
|
||||
case .cancel:
|
||||
self.completion?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController { userSessionNameHostingController }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Updates the name of the device, completing the screen's presentation if successful.
|
||||
private func updateName(_ newName: String) {
|
||||
startLoading()
|
||||
parameters.session.matrixRestClient.setDeviceName(newName, forDevice: parameters.sessionInfo.id) { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard response.isSuccess else {
|
||||
MXLog.debug("[UserSessionNameCoordinator] Rename device (\(self.parameters.sessionInfo.id)) failed")
|
||||
self.userSessionNameViewModel.processError(response.error as NSError?)
|
||||
return
|
||||
}
|
||||
|
||||
self.stopLoading()
|
||||
self.completion?(.sessionNameUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Using an enum for the screen allows you define the different state cases with
|
||||
/// the relevant associated data for each case.
|
||||
enum MockUserSessionNameScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case initialName
|
||||
case empty
|
||||
case changedName
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
UserSessionName.self
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: UserSessionNameViewModel
|
||||
switch self {
|
||||
case .initialName:
|
||||
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
|
||||
case .empty:
|
||||
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
|
||||
viewModel.state.bindings.sessionName = ""
|
||||
case .changedName:
|
||||
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
|
||||
viewModel.state.bindings.sessionName = "iPhone SE"
|
||||
}
|
||||
|
||||
return ([viewModel], AnyView(UserSessionName(viewModel: viewModel.context)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class UserSessionNameUITests: MockScreenTestCase {
|
||||
func testUserSessionNameInitialState() {
|
||||
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.initialName.title)
|
||||
|
||||
let doneButton = app.buttons[VectorL10n.done]
|
||||
XCTAssertTrue(doneButton.exists)
|
||||
XCTAssertFalse(doneButton.isEnabled)
|
||||
}
|
||||
|
||||
func testUserSessionNameEmptyState() {
|
||||
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.empty.title)
|
||||
|
||||
let doneButton = app.buttons[VectorL10n.done]
|
||||
XCTAssertTrue(doneButton.exists)
|
||||
XCTAssertFalse(doneButton.isEnabled)
|
||||
}
|
||||
|
||||
func testUserSessionNameChangedState() {
|
||||
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.changedName.title)
|
||||
|
||||
let doneButton = app.buttons[VectorL10n.done]
|
||||
XCTAssertTrue(doneButton.exists)
|
||||
XCTAssertTrue(doneButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class UserSessionNameViewModelTests: XCTestCase {
|
||||
var viewModel: UserSessionNameViewModelProtocol!
|
||||
var context: UserSessionNameViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testClearingName() {
|
||||
// Given an unedited name.
|
||||
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.")
|
||||
|
||||
// When clearing the name.
|
||||
context.sessionName = ""
|
||||
|
||||
// Then the done button should remain be disabled.
|
||||
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name is empty.")
|
||||
}
|
||||
|
||||
func testChangingName() {
|
||||
// Given an unedited name.
|
||||
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.")
|
||||
|
||||
// When changing the name.
|
||||
context.sessionName = "Alice's iPhone"
|
||||
|
||||
// Then the done button should be enabled.
|
||||
XCTAssertTrue(context.viewState.canUpdateName, "The done button should be enabled when the name has been changed.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
enum UserSessionNameCoordinatorResult {
|
||||
/// The user cancelled the rename operation.
|
||||
case cancel
|
||||
/// The user successfully updated the name of the session.
|
||||
case sessionNameUpdated
|
||||
}
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum UserSessionNameViewModelResult {
|
||||
/// The user cancelled the rename operation.
|
||||
case cancel
|
||||
/// Update the session name to the supplied string.
|
||||
case updateName(String)
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct UserSessionNameViewState: BindableState {
|
||||
var bindings: UserSessionNameBindings
|
||||
/// The current name of the session before any updates are made.
|
||||
let currentName: String
|
||||
|
||||
/// Whether or not to allow the user to update the session name.
|
||||
var canUpdateName: Bool {
|
||||
!bindings.sessionName.isEmpty && bindings.sessionName != currentName
|
||||
}
|
||||
}
|
||||
|
||||
struct UserSessionNameBindings {
|
||||
/// The name input by the user.
|
||||
var sessionName: String
|
||||
/// The currently displayed alert's info value otherwise `nil`.
|
||||
var alertInfo: AlertInfo<Int>?
|
||||
}
|
||||
|
||||
enum UserSessionNameViewAction {
|
||||
/// The user tapped the done button to update the session name.
|
||||
case done
|
||||
/// The user tapped the cancel button.
|
||||
case cancel
|
||||
/// The user tapped the Learn More link.
|
||||
case learnMore
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
typealias UserSessionNameViewModelType = StateStoreViewModel<UserSessionNameViewState, UserSessionNameViewAction>
|
||||
|
||||
class UserSessionNameViewModel: UserSessionNameViewModelType, UserSessionNameViewModelProtocol {
|
||||
var completion: ((UserSessionNameViewModelResult) -> Void)?
|
||||
|
||||
init(sessionInfo: UserSessionInfo) {
|
||||
super.init(initialViewState: UserSessionNameViewState(bindings: .init(sessionName: sessionInfo.name ?? ""),
|
||||
currentName: sessionInfo.name ?? ""))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: UserSessionNameViewAction) {
|
||||
switch viewAction {
|
||||
case .done:
|
||||
completion?(.updateName(state.bindings.sessionName))
|
||||
case .cancel:
|
||||
completion?(.cancel)
|
||||
case .learnMore:
|
||||
#warning("To be implemented as part of PSG-714.")
|
||||
}
|
||||
}
|
||||
|
||||
func processError(_ error: NSError?) {
|
||||
state.bindings.alertInfo = AlertInfo(error: error)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol UserSessionNameViewModelProtocol {
|
||||
var completion: ((UserSessionNameViewModelResult) -> Void)? { get set }
|
||||
var context: UserSessionNameViewModelType.Context { get }
|
||||
|
||||
/// Update the view model to show that an error has occurred.
|
||||
/// - Parameter error: The error to be displayed or `nil` to display a generic alert.
|
||||
func processError(_ error: NSError?)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserSessionName: View {
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
@ObservedObject var viewModel: UserSessionNameViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
SwiftUI.Section {
|
||||
TextField(VectorL10n.manageSessionName, text: $viewModel.sessionName)
|
||||
.autocapitalization(.words)
|
||||
.listRowBackground(theme.colors.background)
|
||||
.introspectTextField {
|
||||
$0.becomeFirstResponder()
|
||||
$0.clearButtonMode = .whileEditing
|
||||
}
|
||||
} header: {
|
||||
Text(VectorL10n.manageSessionName)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
} footer: {
|
||||
textFieldFooter
|
||||
}
|
||||
}
|
||||
.background(theme.colors.system.ignoresSafeArea())
|
||||
.frame(maxHeight: .infinity)
|
||||
.listStyle(.grouped)
|
||||
.listBackgroundColor(theme.colors.system)
|
||||
.navigationTitle(VectorL10n.manageSessionRename)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.accentColor(theme.colors.accent)
|
||||
}
|
||||
|
||||
private var textFieldFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(VectorL10n.manageSessionNameHint)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
|
||||
InlineTextButton(VectorL10n.manageSessionNameInfo("%@"),
|
||||
tappableText: VectorL10n.manageSessionNameInfoLink) {
|
||||
viewModel.send(viewAction: .learnMore)
|
||||
}
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(VectorL10n.cancel) {
|
||||
viewModel.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(VectorL10n.done) {
|
||||
viewModel.send(viewAction: .done)
|
||||
}
|
||||
.disabled(!viewModel.viewState.canUpdateName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct UserSessionName_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockUserSessionNameScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light)
|
||||
.preferredColorScheme(.light)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -14,12 +14,14 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct UserSessionOverviewCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let sessionInfo: UserSessionInfo
|
||||
let sessionsOverviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
|
||||
}
|
||||
|
||||
final class UserSessionOverviewCoordinator: Coordinator, Presentable {
|
||||
@@ -42,7 +44,9 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
|
||||
self.parameters = parameters
|
||||
|
||||
let service = UserSessionOverviewService(session: parameters.session, sessionInfo: parameters.sessionInfo)
|
||||
viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo, service: service)
|
||||
viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo,
|
||||
service: service,
|
||||
sessionsOverviewDataPublisher: parameters.sessionsOverviewDataPublisher)
|
||||
|
||||
hostingController = VectorHostingController(rootView: UserSessionOverview(viewModel: viewModel.context))
|
||||
hostingController.vc_setLargeTitleDisplayMode(.never)
|
||||
@@ -60,8 +64,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
|
||||
|
||||
MXLog.debug("[UserSessionOverviewCoordinator] UserSessionOverviewViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .verifyCurrentSession:
|
||||
break // TODO:
|
||||
case let .verifySession(sessionInfo):
|
||||
self.completion?(.verifySession(sessionInfo))
|
||||
case let .showSessionDetails(sessionInfo: sessionInfo):
|
||||
self.completion?(.openSessionDetails(sessionInfo: sessionInfo))
|
||||
case let .renameSession(sessionInfo):
|
||||
|
||||
+7
-7
@@ -51,7 +51,7 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
|
||||
session = UserSessionInfo(id: "alice",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
@@ -69,14 +69,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
|
||||
session = UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
isVerified: true,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_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.1.1",
|
||||
@@ -87,14 +87,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
|
||||
session = UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
isVerified: true,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
|
||||
applicationName: "Element MacOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "macOS 12.5.1",
|
||||
deviceOS: "macOS",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "My Mac",
|
||||
clientVersion: "1.0.0",
|
||||
@@ -105,14 +105,14 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
|
||||
session = UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
isVerified: true,
|
||||
verificationState: .verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
|
||||
applicationName: "Element MacOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "macOS 12.5.1",
|
||||
deviceOS: "macOS",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "My Mac",
|
||||
clientVersion: "1.0.0",
|
||||
|
||||
+5
-4
@@ -21,15 +21,16 @@ import XCTest
|
||||
|
||||
class UserSessionOverviewViewModelTests: XCTestCase {
|
||||
func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() {
|
||||
let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService())
|
||||
let sessionInfo = createUserSessionInfo()
|
||||
let sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService())
|
||||
|
||||
XCTAssertEqual(sut.state.isPusherEnabled, nil)
|
||||
var modelResult: UserSessionOverviewViewModelResult?
|
||||
sut.completion = { result in
|
||||
modelResult = result
|
||||
}
|
||||
sut.process(viewAction: .verifyCurrentSession)
|
||||
XCTAssertEqual(modelResult, .verifyCurrentSession)
|
||||
sut.process(viewAction: .verifySession)
|
||||
XCTAssertEqual(modelResult, .verifySession(sessionInfo))
|
||||
}
|
||||
|
||||
func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() {
|
||||
@@ -88,7 +89,7 @@ class UserSessionOverviewViewModelTests: XCTestCase {
|
||||
UserSessionInfo(id: "session",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
verificationState: .unverified,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
|
||||
applicationName: "Element iOS",
|
||||
|
||||
+4
-3
@@ -20,6 +20,7 @@ import Foundation
|
||||
|
||||
enum UserSessionOverviewCoordinatorResult {
|
||||
case openSessionDetails(sessionInfo: UserSessionInfo)
|
||||
case verifySession(UserSessionInfo)
|
||||
case renameSession(UserSessionInfo)
|
||||
case logoutOfSession(UserSessionInfo)
|
||||
}
|
||||
@@ -28,7 +29,7 @@ enum UserSessionOverviewCoordinatorResult {
|
||||
|
||||
enum UserSessionOverviewViewModelResult: Equatable {
|
||||
case showSessionDetails(sessionInfo: UserSessionInfo)
|
||||
case verifyCurrentSession
|
||||
case verifySession(UserSessionInfo)
|
||||
case renameSession(UserSessionInfo)
|
||||
case logoutOfSession(UserSessionInfo)
|
||||
}
|
||||
@@ -36,7 +37,7 @@ enum UserSessionOverviewViewModelResult: Equatable {
|
||||
// MARK: View
|
||||
|
||||
struct UserSessionOverviewViewState: BindableState {
|
||||
let cardViewData: UserSessionCardViewData
|
||||
var cardViewData: UserSessionCardViewData
|
||||
let isCurrentSession: Bool
|
||||
var isPusherEnabled: Bool?
|
||||
var remotelyTogglingPushersAvailable: Bool
|
||||
@@ -44,7 +45,7 @@ struct UserSessionOverviewViewState: BindableState {
|
||||
}
|
||||
|
||||
enum UserSessionOverviewViewAction {
|
||||
case verifyCurrentSession
|
||||
case verifySession
|
||||
case viewSessionDetails
|
||||
case togglePushNotifications
|
||||
case renameSession
|
||||
|
||||
+25
-3
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias UserSessionOverviewViewModelType = StateStoreViewModel<UserSessionOverviewViewState, UserSessionOverviewViewAction>
|
||||
@@ -26,7 +27,13 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(sessionInfo: UserSessionInfo, service: UserSessionOverviewServiceProtocol) {
|
||||
init(sessionInfo: UserSessionInfo,
|
||||
service: UserSessionOverviewServiceProtocol,
|
||||
sessionsOverviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never> = .init(.init(currentSession: nil,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false))) {
|
||||
self.sessionInfo = sessionInfo
|
||||
self.service = service
|
||||
|
||||
@@ -39,6 +46,21 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
|
||||
super.init(initialViewState: state)
|
||||
|
||||
startObservingService()
|
||||
|
||||
sessionsOverviewDataPublisher.sink { [weak self] overviewData in
|
||||
guard let self = self else { return }
|
||||
|
||||
var updatedInfo: UserSessionInfo?
|
||||
if let currentSession = overviewData.currentSession, currentSession.id == sessionInfo.id {
|
||||
updatedInfo = currentSession
|
||||
} else if let otherSession = overviewData.otherSessions.first(where: { $0.id == sessionInfo.id }) {
|
||||
updatedInfo = otherSession
|
||||
}
|
||||
|
||||
guard let updatedInfo = updatedInfo else { return }
|
||||
self.state.cardViewData = UserSessionCardViewData(sessionInfo: updatedInfo)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func startObservingService() {
|
||||
@@ -62,8 +84,8 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
|
||||
|
||||
override func process(viewAction: UserSessionOverviewViewAction) {
|
||||
switch viewAction {
|
||||
case .verifyCurrentSession:
|
||||
completion?(.verifyCurrentSession)
|
||||
case .verifySession:
|
||||
completion?(.verifySession(sessionInfo))
|
||||
case .viewSessionDetails:
|
||||
completion?(.showSessionDetails(sessionInfo: sessionInfo))
|
||||
case .togglePushNotifications:
|
||||
|
||||
@@ -24,7 +24,7 @@ struct UserSessionOverview: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
UserSessionCardView(viewData: viewModel.viewState.cardViewData, onVerifyAction: { _ in
|
||||
viewModel.send(viewAction: .verifyCurrentSession)
|
||||
viewModel.send(viewAction: .verifySession)
|
||||
},
|
||||
onViewDetailsAction: { _ in
|
||||
viewModel.send(viewAction: .viewSessionDetails)
|
||||
@@ -47,6 +47,7 @@ struct UserSessionOverview: View {
|
||||
|
||||
SwiftUI.Section {
|
||||
UserSessionOverviewItem(title: VectorL10n.manageSessionSignOut,
|
||||
alignment: .center,
|
||||
isDestructive: true) {
|
||||
viewModel.send(viewAction: .logoutOfSession)
|
||||
}
|
||||
@@ -66,8 +67,11 @@ struct UserSessionOverview: View {
|
||||
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
Image(systemName: "ellipsis")
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.offset(x: 4) // Re-align the symbol after applying padding.
|
||||
}
|
||||
}
|
||||
.accentColor(theme.colors.accent)
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ struct UserSessionOverviewItem: View {
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
let title: String
|
||||
var alignment: Alignment = .leading
|
||||
var showsChevron = false
|
||||
var isDestructive = false
|
||||
var onBackgroundTap: (() -> Void)?
|
||||
@@ -32,7 +33,7 @@ struct UserSessionOverviewItem: View {
|
||||
Text(title)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(textColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
|
||||
if showsChevron {
|
||||
Image(Asset.Images.chevron.name)
|
||||
|
||||
+4
-4
@@ -19,6 +19,7 @@ import SwiftUI
|
||||
|
||||
struct UserSessionsOverviewCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let service: UserSessionsOverviewService
|
||||
}
|
||||
|
||||
final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
@@ -36,10 +37,9 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
|
||||
init(parameters: UserSessionsOverviewCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
service = parameters.service
|
||||
|
||||
let dataProvider = UserSessionsDataProvider(session: parameters.session)
|
||||
service = UserSessionsOverviewService(dataProvider: dataProvider)
|
||||
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
|
||||
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: parameters.service)
|
||||
|
||||
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
|
||||
hostingViewController.vc_setLargeTitleDisplayMode(.never)
|
||||
@@ -60,7 +60,7 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
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):
|
||||
|
||||
+10
@@ -44,6 +44,16 @@ 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)
|
||||
}
|
||||
|
||||
+2
@@ -28,6 +28,8 @@ 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
|
||||
|
||||
+45
-23
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import MatrixSDK
|
||||
|
||||
class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
@@ -22,20 +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: [],
|
||||
linkDeviceEnabled: false)
|
||||
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false))
|
||||
sessionInfos = []
|
||||
setupInitialOverviewData()
|
||||
listenForSessionUpdates()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -47,9 +49,10 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
self.sessionInfos = self.sortedSessionInfos(from: devices)
|
||||
Task { @MainActor in
|
||||
let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable()
|
||||
self.overviewData = self.sessionsOverviewData(from: self.sessionInfos,
|
||||
linkDeviceEnabled: linkDeviceEnabled ?? false)
|
||||
completion(.success(self.overviewData))
|
||||
let overviewData = self.sessionsOverviewData(from: self.sessionInfos,
|
||||
linkDeviceEnabled: linkDeviceEnabled ?? false)
|
||||
self.overviewDataPublisher.send(overviewData)
|
||||
completion(.success(overviewData))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
@@ -58,25 +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: [],
|
||||
linkDeviceEnabled: false)
|
||||
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: currentSessionInfo,
|
||||
unverifiedSessions: currentSessionInfo.verificationState == .verified ? [] : [currentSessionInfo],
|
||||
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false))
|
||||
}
|
||||
|
||||
private func getCurrentSessionInfo() -> UserSessionInfo? {
|
||||
@@ -96,14 +117,15 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
private func sessionsOverviewData(from allSessions: [UserSessionInfo],
|
||||
linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
|
||||
UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
|
||||
unverifiedSessions: allSessions.filter { !$0.isVerified },
|
||||
unverifiedSessions: allSessions.filter { $0.verificationState != .verified },
|
||||
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)
|
||||
@@ -122,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)
|
||||
}
|
||||
@@ -140,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"],
|
||||
|
||||
+36
-36
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
enum Mode {
|
||||
@@ -27,17 +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: [],
|
||||
linkDeviceEnabled: false)
|
||||
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false))
|
||||
}
|
||||
|
||||
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
|
||||
@@ -46,47 +46,47 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
|
||||
switch mode {
|
||||
case .noOtherSessions:
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false)
|
||||
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false))
|
||||
case .onlyUnverifiedSessions:
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: unverifiedSessions + [currentSession],
|
||||
inactiveSessions: [],
|
||||
otherSessions: unverifiedSessions,
|
||||
linkDeviceEnabled: false)
|
||||
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
|
||||
unverifiedSessions: unverifiedSessions + [mockCurrentSession],
|
||||
inactiveSessions: [],
|
||||
otherSessions: unverifiedSessions,
|
||||
linkDeviceEnabled: false))
|
||||
case .onlyInactiveSessions:
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: inactiveSessions,
|
||||
otherSessions: inactiveSessions,
|
||||
linkDeviceEnabled: false)
|
||||
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,
|
||||
linkDeviceEnabled: true)
|
||||
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",
|
||||
@@ -105,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 - 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",
|
||||
@@ -121,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",
|
||||
@@ -137,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",
|
||||
|
||||
+15
-2
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct UserSessionsOverviewData {
|
||||
let currentSession: UserSessionInfo?
|
||||
@@ -25,10 +25,23 @@ struct UserSessionsOverviewData {
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
+14
@@ -73,4 +73,18 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -52,9 +52,15 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
|
||||
viewModel.process(viewAction: .verifyCurrentSession)
|
||||
XCTAssertEqual(result, .verifyCurrentSession)
|
||||
|
||||
result = nil
|
||||
viewModel.process(viewAction: .viewAllInactiveSessions)
|
||||
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive))
|
||||
|
||||
result = nil
|
||||
viewModel.process(viewAction: .viewAllOtherSessions)
|
||||
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .all))
|
||||
|
||||
result = nil
|
||||
viewModel.process(viewAction: .linkDevice)
|
||||
XCTAssertEqual(result, .linkDevice)
|
||||
}
|
||||
@@ -70,7 +76,7 @@ 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
|
||||
}
|
||||
@@ -78,7 +84,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
|
||||
viewModel.process(viewAction: .viewCurrentSessionDetails)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import Foundation
|
||||
// MARK: - Coordinator
|
||||
|
||||
enum UserSessionsOverviewCoordinatorResult {
|
||||
case verifyCurrentSession
|
||||
case renameSession(UserSessionInfo)
|
||||
case logoutOfSession(UserSessionInfo)
|
||||
case openSessionOverview(sessionInfo: UserSessionInfo)
|
||||
|
||||
+15
-15
@@ -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
|
||||
@@ -40,19 +45,19 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
case .verifyCurrentSession:
|
||||
completion?(.verifyCurrentSession)
|
||||
case .renameCurrentSession:
|
||||
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
|
||||
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
|
||||
assertionFailure("Missing current session")
|
||||
return
|
||||
}
|
||||
completion?(.renameSession(currentSessionInfo))
|
||||
case .logoutOfCurrentSession:
|
||||
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
|
||||
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
|
||||
assertionFailure("Missing current session")
|
||||
return
|
||||
}
|
||||
completion?(.logoutOfSession(currentSessionInfo))
|
||||
case .viewCurrentSessionDetails:
|
||||
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
|
||||
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
|
||||
assertionFailure("Missing current session")
|
||||
return
|
||||
}
|
||||
@@ -62,8 +67,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
case .viewAllInactiveSessions:
|
||||
showSessions(filteredBy: .inactive)
|
||||
case .viewAllOtherSessions:
|
||||
// TODO: showSessions(filteredBy: .all)
|
||||
break
|
||||
showSessions(filteredBy: .all)
|
||||
case .tapUserSession(let sessionId):
|
||||
guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else {
|
||||
assertionFailure("Missing session info")
|
||||
@@ -92,19 +96,15 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ struct UserSessionListPreview: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in
|
||||
ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in
|
||||
let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo)
|
||||
|
||||
UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in
|
||||
|
||||
+10
-2
@@ -22,7 +22,7 @@ struct UserSessionListItemViewDataFactory {
|
||||
sessionDisplayName: sessionInfo.name)
|
||||
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
|
||||
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
|
||||
isVerified: sessionInfo.isVerified)
|
||||
verificationState: sessionInfo.verificationState)
|
||||
return UserSessionListItemViewData(sessionId: sessionInfo.id,
|
||||
sessionName: sessionName,
|
||||
sessionDetails: sessionDetails,
|
||||
@@ -50,7 +50,15 @@ struct UserSessionListItemViewDataFactory {
|
||||
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
|
||||
let sessionDetailsString: String
|
||||
|
||||
let sessionStatusText = sessionInfo.isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
|
||||
let sessionStatusText: String
|
||||
switch sessionInfo.verificationState {
|
||||
case .verified:
|
||||
sessionStatusText = VectorL10n.userSessionVerifiedShort
|
||||
case .unverified:
|
||||
sessionStatusText = VectorL10n.userSessionUnverifiedShort
|
||||
case .unknown:
|
||||
sessionStatusText = VectorL10n.userSessionVerificationUnknownShort
|
||||
}
|
||||
|
||||
var lastActivityDateString: String?
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -21,6 +21,8 @@ struct UserSessionsOverview: View {
|
||||
|
||||
@ObservedObject var viewModel: UserSessionsOverviewViewModel.Context
|
||||
|
||||
private let maxOtherSessionsToDisplay = 5
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -121,8 +123,10 @@ struct UserSessionsOverview: View {
|
||||
|
||||
private var currentSessionMenu: some View {
|
||||
Menu {
|
||||
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
|
||||
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
|
||||
SwiftUI.Section {
|
||||
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
|
||||
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 15, *) {
|
||||
@@ -135,18 +139,27 @@ struct UserSessionsOverview: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user