mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
Add tests for linking a new device. (#4934)
* Replace GrantLoginWithQrCodeHandlerSDKMock with LinkNewDeviceServiceMock. Add tests for all initial states on the QRCodeLoginScreen. * Add tests for linking both mobile and desktop devices. * Add UI tests for linking a new device. * Don't show the Link Desktop Computer button when running on macOS. This mirrors the decision to hide the Sign In With QR Code button on the start screen.
This commit is contained in:
@@ -659,6 +659,7 @@
|
||||
7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; };
|
||||
743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; };
|
||||
748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; };
|
||||
74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */; };
|
||||
7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; };
|
||||
754602A7B2AAD443C4228ED4 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; };
|
||||
755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */; };
|
||||
@@ -785,7 +786,6 @@
|
||||
88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */; };
|
||||
890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; };
|
||||
89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; };
|
||||
89261D215E4A432E887CD156 /* GrantLoginWithQrCodeHandlerSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */; };
|
||||
8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; };
|
||||
89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */; };
|
||||
899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; };
|
||||
@@ -2544,6 +2544,7 @@
|
||||
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
|
||||
B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenModels.swift; sourceTree = "<group>"; };
|
||||
BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferenceTests.swift; sourceTree = "<group>"; };
|
||||
BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceServiceMock.swift; sourceTree = "<group>"; };
|
||||
BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenModels.swift; sourceTree = "<group>"; };
|
||||
BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreen.swift; sourceTree = "<group>"; };
|
||||
BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -2884,7 +2885,6 @@
|
||||
FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformCursorView.swift; sourceTree = "<group>"; };
|
||||
FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = "<group>"; };
|
||||
FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; };
|
||||
FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrantLoginWithQrCodeHandlerSDKMock.swift; sourceTree = "<group>"; };
|
||||
FC3797A2325BE44FFB478BE9 /* LeaveSpaceRoomDetailsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceRoomDetailsCell.swift; sourceTree = "<group>"; };
|
||||
FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderMock.swift; sourceTree = "<group>"; };
|
||||
FC9044BE0E4A66F5B963E834 /* AudioFileEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileEventsTimelineView.swift; sourceTree = "<group>"; };
|
||||
@@ -3623,6 +3623,7 @@
|
||||
867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */,
|
||||
9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */,
|
||||
7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */,
|
||||
BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */,
|
||||
6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */,
|
||||
8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */,
|
||||
840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */,
|
||||
@@ -6430,7 +6431,6 @@
|
||||
children = (
|
||||
8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */,
|
||||
0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */,
|
||||
FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */,
|
||||
5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */,
|
||||
580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */,
|
||||
);
|
||||
@@ -8024,7 +8024,6 @@
|
||||
D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */,
|
||||
55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */,
|
||||
E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */,
|
||||
89261D215E4A432E887CD156 /* GrantLoginWithQrCodeHandlerSDKMock.swift in Sources */,
|
||||
F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */,
|
||||
E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */,
|
||||
0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */,
|
||||
@@ -8124,6 +8123,7 @@
|
||||
C16E25C41B858BF27E0C4FC6 /* LinkNewDeviceScreenViewModel.swift in Sources */,
|
||||
92FE657CDFAFE3031576EB43 /* LinkNewDeviceScreenViewModelProtocol.swift in Sources */,
|
||||
A37BFB32EAB8AEF6DD5BA0DC /* LinkNewDeviceService.swift in Sources */,
|
||||
74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */,
|
||||
866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */,
|
||||
6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */,
|
||||
D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */,
|
||||
|
||||
@@ -106,6 +106,7 @@ extension ClientProxyMock {
|
||||
resetIdentityReturnValue = .success(IdentityResetHandleSDKMock(.init()))
|
||||
|
||||
spaceService = SpaceServiceProxyMock(configuration.spaceServiceConfiguration)
|
||||
linkNewDeviceServiceReturnValue = LinkNewDeviceServiceMock(.init())
|
||||
|
||||
roomForIdentifierClosure = { [weak self] identifier in
|
||||
if let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) {
|
||||
|
||||
@@ -4049,13 +4049,13 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
||||
return linkNewDeviceServiceCallsCount > 0
|
||||
}
|
||||
|
||||
var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceService!
|
||||
var linkNewDeviceServiceReturnValue: LinkNewDeviceService! {
|
||||
var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceServiceProtocol!
|
||||
var linkNewDeviceServiceReturnValue: LinkNewDeviceServiceProtocol! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return linkNewDeviceServiceUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: LinkNewDeviceService? = nil
|
||||
var returnValue: LinkNewDeviceServiceProtocol? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = linkNewDeviceServiceUnderlyingReturnValue
|
||||
}
|
||||
@@ -4073,9 +4073,9 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
var linkNewDeviceServiceClosure: (() -> LinkNewDeviceService)?
|
||||
var linkNewDeviceServiceClosure: (() -> LinkNewDeviceServiceProtocol)?
|
||||
|
||||
func linkNewDeviceService() -> LinkNewDeviceService {
|
||||
func linkNewDeviceService() -> LinkNewDeviceServiceProtocol {
|
||||
linkNewDeviceServiceCallsCount += 1
|
||||
if let linkNewDeviceServiceClosure = linkNewDeviceServiceClosure {
|
||||
return linkNewDeviceServiceClosure()
|
||||
@@ -10917,6 +10917,143 @@ class KnockedRoomProxyMock: KnockedRoomProxyProtocol, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - linkMobileDevice
|
||||
|
||||
var linkMobileDeviceUnderlyingCallsCount = 0
|
||||
var linkMobileDeviceCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return linkMobileDeviceUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = linkMobileDeviceUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
linkMobileDeviceUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
linkMobileDeviceUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var linkMobileDeviceCalled: Bool {
|
||||
return linkMobileDeviceCallsCount > 0
|
||||
}
|
||||
|
||||
var linkMobileDeviceUnderlyingReturnValue: LinkNewDeviceService.LinkMobileProgressPublisher!
|
||||
var linkMobileDeviceReturnValue: LinkNewDeviceService.LinkMobileProgressPublisher! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return linkMobileDeviceUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: LinkNewDeviceService.LinkMobileProgressPublisher? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = linkMobileDeviceUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
linkMobileDeviceUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
linkMobileDeviceUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var linkMobileDeviceClosure: (() -> LinkNewDeviceService.LinkMobileProgressPublisher)?
|
||||
|
||||
func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher {
|
||||
linkMobileDeviceCallsCount += 1
|
||||
if let linkMobileDeviceClosure = linkMobileDeviceClosure {
|
||||
return linkMobileDeviceClosure()
|
||||
} else {
|
||||
return linkMobileDeviceReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - linkDesktopDevice
|
||||
|
||||
var linkDesktopDeviceWithUnderlyingCallsCount = 0
|
||||
var linkDesktopDeviceWithCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return linkDesktopDeviceWithUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = linkDesktopDeviceWithUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
linkDesktopDeviceWithUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
linkDesktopDeviceWithUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var linkDesktopDeviceWithCalled: Bool {
|
||||
return linkDesktopDeviceWithCallsCount > 0
|
||||
}
|
||||
var linkDesktopDeviceWithReceivedScannedQRData: Data?
|
||||
var linkDesktopDeviceWithReceivedInvocations: [Data] = []
|
||||
|
||||
var linkDesktopDeviceWithUnderlyingReturnValue: LinkNewDeviceService.LinkDesktopProgressPublisher!
|
||||
var linkDesktopDeviceWithReturnValue: LinkNewDeviceService.LinkDesktopProgressPublisher! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return linkDesktopDeviceWithUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: LinkNewDeviceService.LinkDesktopProgressPublisher? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = linkDesktopDeviceWithUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
linkDesktopDeviceWithUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
linkDesktopDeviceWithUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var linkDesktopDeviceWithClosure: ((Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher)?
|
||||
|
||||
func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher {
|
||||
linkDesktopDeviceWithCallsCount += 1
|
||||
linkDesktopDeviceWithReceivedScannedQRData = scannedQRData
|
||||
DispatchQueue.main.async {
|
||||
self.linkDesktopDeviceWithReceivedInvocations.append(scannedQRData)
|
||||
}
|
||||
if let linkDesktopDeviceWithClosure = linkDesktopDeviceWithClosure {
|
||||
return linkDesktopDeviceWithClosure(scannedQRData)
|
||||
} else {
|
||||
return linkDesktopDeviceWithReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
class MediaLoaderMock: MediaLoaderProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - loadMediaContentForSource
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension LinkNewDeviceServiceMock {
|
||||
static var mockQRCodeImage: UIImage {
|
||||
mockBase64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage()
|
||||
}
|
||||
|
||||
static let mockBase64QRCode = """
|
||||
TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0
|
||||
cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns
|
||||
aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo
|
||||
dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv
|
||||
"""
|
||||
|
||||
struct Configuration {
|
||||
var linkMobileProgressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher = .init(.starting)
|
||||
var linkDesktopProgressPublisher: LinkNewDeviceService.LinkDesktopProgressPublisher = .init(.starting)
|
||||
}
|
||||
|
||||
convenience init(_ configuration: Configuration) {
|
||||
self.init()
|
||||
|
||||
linkMobileDeviceReturnValue = configuration.linkMobileProgressPublisher
|
||||
linkDesktopDeviceWithReturnValue = configuration.linkDesktopProgressPublisher
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import MatrixRustSDKMocks
|
||||
|
||||
extension GrantLoginWithQrCodeHandlerSDKMock {
|
||||
struct Configuration {
|
||||
var generateDelay: Duration = .seconds(0)
|
||||
var generatedBase64QRCode = """
|
||||
TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0
|
||||
cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns
|
||||
aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo
|
||||
dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv
|
||||
"""
|
||||
}
|
||||
|
||||
convenience init(_ configuration: Configuration) {
|
||||
self.init()
|
||||
|
||||
generateProgressListenerClosure = { listener in
|
||||
Task {
|
||||
try await Task.sleep(for: configuration.generateDelay)
|
||||
let bytes = Data(base64Encoded: configuration.generatedBase64QRCode) ?? Data()
|
||||
try listener.onUpdate(state: .qrReady(qrCode: .fromBytes(bytes: bytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ enum LinkNewDeviceScreenViewModelAction {
|
||||
struct LinkNewDeviceScreenViewState: BindableState {
|
||||
enum Mode: Equatable { case loading, readyToLink(isGeneratingCode: Bool), notSupported }
|
||||
var mode: Mode = .loading
|
||||
|
||||
let showLinkDesktopComputerButton: Bool
|
||||
}
|
||||
|
||||
enum LinkNewDeviceScreenViewAction {
|
||||
|
||||
@@ -22,7 +22,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
||||
init(clientProxy: ClientProxyProtocol) {
|
||||
self.clientProxy = clientProxy
|
||||
|
||||
super.init(initialViewState: LinkNewDeviceScreenViewState())
|
||||
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
|
||||
|
||||
super.init(initialViewState: LinkNewDeviceScreenViewState(showLinkDesktopComputerButton: isQRCodeScanningSupported))
|
||||
|
||||
Task { await checkQRCodeLoginSupport() }
|
||||
}
|
||||
|
||||
@@ -70,11 +70,13 @@ struct LinkNewDeviceScreen: View {
|
||||
.buttonStyle(.compound(.primary))
|
||||
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.mobileDevice)
|
||||
|
||||
Button { context.send(viewAction: .linkDesktopComputer) } label: {
|
||||
Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer)
|
||||
if context.viewState.showLinkDesktopComputerButton {
|
||||
Button { context.send(viewAction: .linkDesktopComputer) } label: {
|
||||
Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
|
||||
}
|
||||
.disabled(isGeneratingCode)
|
||||
case .notSupported:
|
||||
@@ -97,8 +99,6 @@ struct LinkNewDeviceScreen: View {
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
import MatrixRustSDKMocks
|
||||
|
||||
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false))
|
||||
static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true))
|
||||
@@ -143,7 +143,6 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
|
||||
return false
|
||||
}
|
||||
}
|
||||
clientProxy.linkNewDeviceServiceReturnValue = .init(handler: GrantLoginWithQrCodeHandlerSDKMock(.init(generateDelay: .seconds(20))))
|
||||
|
||||
let viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ enum QRCodeLoginScreenMode {
|
||||
/// Configures the screen to login this device by scanning a QR code.
|
||||
case login(QRCodeLoginServiceProtocol)
|
||||
/// Configures the screen to link another device by scanning a QR code.
|
||||
case linkDesktop(LinkNewDeviceService)
|
||||
case linkDesktop(LinkNewDeviceServiceProtocol)
|
||||
/// Configures the screen to link another device by showing it a QR code.
|
||||
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||
}
|
||||
@@ -180,6 +180,13 @@ enum QRCodeLoginState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
var isDisplayQR: Bool {
|
||||
switch self {
|
||||
case .displayQR: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var isError: Bool {
|
||||
switch self {
|
||||
case .error, .scan(.scanFailed): true
|
||||
|
||||
@@ -153,7 +153,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
||||
|
||||
// TODO: when user cancels in UI then the underlying login needs to be cancelled too. It's unclear if we have that exposed in the bindings yet.
|
||||
|
||||
private func handleScan(qrData: Data, linkService: LinkNewDeviceService) {
|
||||
private func handleScan(qrData: Data, linkService: LinkNewDeviceServiceProtocol) {
|
||||
guard currentTask == nil else { return }
|
||||
|
||||
state.state = .scan(.connecting)
|
||||
|
||||
@@ -337,11 +337,7 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
|
||||
|
||||
// Showing
|
||||
static let showingStateViewModel = {
|
||||
let base64QRCode = GrantLoginWithQrCodeHandlerSDKMock.Configuration().generatedBase64QRCode
|
||||
let image = base64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage()
|
||||
return QRCodeLoginScreenViewModel.mock(state: .displayQR(image))
|
||||
}()
|
||||
static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(LinkNewDeviceServiceMock.mockQRCodeImage))
|
||||
|
||||
// Displaying codes
|
||||
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
|
||||
|
||||
@@ -10,7 +10,15 @@ import CoreImage.CIFilterBuiltins
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
class LinkNewDeviceService {
|
||||
// sourcery: AutoMockable
|
||||
protocol LinkNewDeviceServiceProtocol {
|
||||
/// Links a new device by showing it a QR code.
|
||||
func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher
|
||||
/// Links a new device using a QR code generated by said device.
|
||||
func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher
|
||||
}
|
||||
|
||||
class LinkNewDeviceService: LinkNewDeviceServiceProtocol {
|
||||
/// Publishes the progress of linking a new device by showing it a QR code.
|
||||
typealias LinkMobileProgressPublisher = CurrentValuePublisher<LinkMobileProgress, QRCodeLoginError>
|
||||
/// Publishes the progress of linking a new device by scanning a QR code generated by said device.
|
||||
|
||||
@@ -716,7 +716,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func linkNewDeviceService() -> LinkNewDeviceService {
|
||||
func linkNewDeviceService() -> LinkNewDeviceServiceProtocol {
|
||||
LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler())
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ protocol ClientProxyProtocol: AnyObject {
|
||||
|
||||
func removeUserAvatar() async -> Result<Void, ClientProxyError>
|
||||
|
||||
func linkNewDeviceService() -> LinkNewDeviceService
|
||||
func linkNewDeviceService() -> LinkNewDeviceServiceProtocol
|
||||
|
||||
func deactivateAccount(password: String?, eraseData: Bool) async -> Result<Void, ClientProxyError>
|
||||
|
||||
|
||||
@@ -736,9 +736,15 @@ class MockScreen: Identifiable {
|
||||
|
||||
return navigationStackCoordinator
|
||||
case .linkNewDevice:
|
||||
let linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError> = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
|
||||
let linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher()))
|
||||
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService
|
||||
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
|
||||
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init()),
|
||||
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
bugReportService: BugReportServiceMock(.init()),
|
||||
elementCallService: ElementCallServiceMock(.init()),
|
||||
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
|
||||
|
||||
@@ -11,16 +11,38 @@ import XCTest
|
||||
class LinkNewDeviceTests: XCTestCase {
|
||||
enum Step {
|
||||
static let selectDevice = 1
|
||||
static let linkMobileDevice = 2
|
||||
static let linkDesktopComputer = 3
|
||||
static let dismissed = 99
|
||||
}
|
||||
|
||||
func testFlow() async throws {
|
||||
// Root screen
|
||||
let app = Application.launch(.linkNewDevice)
|
||||
try await app.assertScreenshot(step: Step.selectDevice)
|
||||
|
||||
// Link showing a QR code
|
||||
let mobileDeviceButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.mobileDevice]
|
||||
mobileDeviceButton.tap()
|
||||
try await app.assertScreenshot(step: Step.linkMobileDevice)
|
||||
|
||||
// Pop back to the root screen
|
||||
let backButton = app.buttons["Link new device"]
|
||||
backButton.tap()
|
||||
try await app.assertScreenshot(step: Step.selectDevice)
|
||||
|
||||
// Link scanning a QR code
|
||||
let desktopComputerButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.desktopComputer]
|
||||
desktopComputerButton.tap()
|
||||
try await app.assertScreenshot(step: Step.linkDesktopComputer)
|
||||
|
||||
// Pop back to the root screen
|
||||
backButton.tap()
|
||||
try await app.assertScreenshot(step: Step.selectDevice)
|
||||
|
||||
// Dismiss the flow
|
||||
let cancelButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.cancel]
|
||||
cancelButton.tap()
|
||||
|
||||
try await app.assertScreenshot(step: Step.dismissed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d01d3cb0985b5720c032a0e157bbe7e453f4b7cba00d050f7731e8a821814be0
|
||||
size 190548
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4f35198629723417903f1709bffd656ec968ce9ec135be1f52df5e7fa41352d5
|
||||
size 173283
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d64aface8fd46495e451a982c47f477f9381a1e669778f027582bc968537d4ce
|
||||
size 164767
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1fc3df2dca28137e09abdb4a411168fb1c68cfab92bcaf40951351ec9812722
|
||||
size 136852
|
||||
@@ -9,41 +9,63 @@
|
||||
import Combine
|
||||
import XCTest
|
||||
|
||||
import MatrixRustSDK
|
||||
|
||||
@testable import ElementX
|
||||
import MatrixRustSDKMocks
|
||||
|
||||
@MainActor
|
||||
final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||
private var qrProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
|
||||
private var qrServiceMock: QRCodeLoginServiceMock!
|
||||
private var appMediatorMock: AppMediatorMock!
|
||||
private var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
|
||||
private var qrCodeLoginService: QRCodeLoginServiceMock!
|
||||
|
||||
private var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>!
|
||||
private var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>!
|
||||
private var linkNewDeviceService: LinkNewDeviceServiceMock!
|
||||
|
||||
private var appMediator: AppMediatorMock!
|
||||
|
||||
private var viewModel: QRCodeLoginScreenViewModelProtocol!
|
||||
|
||||
private var context: QRCodeLoginScreenViewModelType.Context {
|
||||
viewModel.context
|
||||
}
|
||||
private var context: QRCodeLoginScreenViewModelType.Context { viewModel.context }
|
||||
|
||||
override func setUp() {
|
||||
qrProgressSubject = .init(.starting)
|
||||
qrServiceMock = QRCodeLoginServiceMock()
|
||||
qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher()
|
||||
appMediatorMock = AppMediatorMock.default
|
||||
viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock),
|
||||
canSignInManually: true,
|
||||
appMediator: appMediatorMock)
|
||||
}
|
||||
|
||||
func testInitialState() {
|
||||
func testLoginInitialState() {
|
||||
setupViewModel(mode: .login)
|
||||
|
||||
XCTAssertEqual(context.viewState.state, .loginInstructions)
|
||||
XCTAssertNil(context.qrResult)
|
||||
XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled)
|
||||
XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled)
|
||||
XCTAssertFalse(appMediatorMock.openAppSettingsCalled)
|
||||
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
|
||||
XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled)
|
||||
XCTAssertFalse(appMediator.openAppSettingsCalled)
|
||||
|
||||
XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled)
|
||||
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
|
||||
}
|
||||
|
||||
func testLinkDesktopInitialState() {
|
||||
setupViewModel(mode: .linkDesktop)
|
||||
|
||||
XCTAssertEqual(context.viewState.state, .linkDesktopInstructions)
|
||||
XCTAssertNil(context.qrResult)
|
||||
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
|
||||
XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled)
|
||||
XCTAssertFalse(appMediator.openAppSettingsCalled)
|
||||
|
||||
XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled)
|
||||
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
|
||||
}
|
||||
|
||||
func testLinkMobileInitialState() {
|
||||
setupViewModel(mode: .linkMobile)
|
||||
|
||||
XCTAssertTrue(context.viewState.state.isDisplayQR)
|
||||
XCTAssertTrue(linkNewDeviceService.linkMobileDeviceCalled)
|
||||
|
||||
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
|
||||
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
|
||||
XCTAssertNil(context.qrResult)
|
||||
}
|
||||
|
||||
func testRequestCameraPermission() async throws {
|
||||
appMediatorMock.requestAuthorizationIfNeededReturnValue = false
|
||||
setupViewModel(mode: .login)
|
||||
appMediator.requestAuthorizationIfNeededReturnValue = false
|
||||
XCTAssert(context.viewState.state == .loginInstructions)
|
||||
|
||||
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
|
||||
@@ -51,15 +73,16 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||
}
|
||||
context.send(viewAction: .startScan)
|
||||
try await deferred.fulfill()
|
||||
XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled)
|
||||
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
|
||||
|
||||
context.send(viewAction: .errorAction(.openSettings))
|
||||
await Task.yield()
|
||||
XCTAssertTrue(appMediatorMock.openAppSettingsCalled)
|
||||
XCTAssertTrue(appMediator.openAppSettingsCalled)
|
||||
XCTAssertNil(context.qrResult)
|
||||
}
|
||||
|
||||
func testLogin() async throws {
|
||||
setupViewModel(mode: .login)
|
||||
XCTAssert(context.viewState.state == .loginInstructions)
|
||||
|
||||
var deferred = deferFulfillment(context.$viewState) { state in
|
||||
@@ -67,7 +90,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||
}
|
||||
context.send(viewAction: .startScan)
|
||||
try await deferred.fulfill()
|
||||
XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled)
|
||||
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
|
||||
|
||||
deferred = deferFulfillment(context.$viewState) { state in
|
||||
state.state == .scan(.connecting)
|
||||
@@ -78,13 +101,13 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||
deferred = deferFulfillment(context.$viewState) { state in
|
||||
state.state == .displayCode(.deviceCode("01"))
|
||||
}
|
||||
qrProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01"))
|
||||
qrLoginProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01"))
|
||||
try await deferred.fulfill()
|
||||
|
||||
deferred = deferFulfillment(context.$viewState) { state in
|
||||
state.state == .displayCode(.verificationCode("ABCDEF"))
|
||||
}
|
||||
qrProgressSubject.send(.waitingForToken(userCode: "ABCDEF"))
|
||||
qrLoginProgressSubject.send(.waitingForToken(userCode: "ABCDEF"))
|
||||
try await deferred.fulfill()
|
||||
|
||||
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||
@@ -93,7 +116,109 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||
default: false
|
||||
}
|
||||
}
|
||||
qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
|
||||
qrLoginProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
|
||||
try await deferredAction.fulfill()
|
||||
}
|
||||
|
||||
func testLinkDesktopComputer() async throws {
|
||||
setupViewModel(mode: .linkDesktop)
|
||||
XCTAssert(context.viewState.state == .linkDesktopInstructions)
|
||||
|
||||
var deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.scanning) }
|
||||
context.send(viewAction: .startScan)
|
||||
try await deferred.fulfill()
|
||||
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
|
||||
|
||||
deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.connecting) }
|
||||
context.qrResult = .init()
|
||||
try await deferred.fulfill()
|
||||
|
||||
deferred = deferFulfillment(context.$viewState) { $0.state == .displayCode(.deviceCode("01")) }
|
||||
linkDesktopProgressSubject.send(.establishingSecureChannel(checkCodeString: "01"))
|
||||
try await deferred.fulfill()
|
||||
|
||||
var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||
guard case .requestOIDCAuthorisation = action else { return false }
|
||||
return true
|
||||
}
|
||||
linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
|
||||
try await deferredAction.fulfill()
|
||||
|
||||
let currentState = context.viewState.state
|
||||
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
|
||||
linkDesktopProgressSubject.send(.syncingSecrets)
|
||||
try await deferredFailure.fulfill()
|
||||
|
||||
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||
guard case .dismiss = action else { return false }
|
||||
return true
|
||||
}
|
||||
linkDesktopProgressSubject.send(.done)
|
||||
try await deferredAction.fulfill()
|
||||
}
|
||||
|
||||
func testLinkMobileDevice() async throws {
|
||||
setupViewModel(mode: .linkMobile)
|
||||
XCTAssert(context.viewState.state.isDisplayQR)
|
||||
|
||||
let checkCodeSender = CheckCodeSenderSDKMock()
|
||||
let checkCodeSenderProxy = CheckCodeSenderProxy(underlyingSender: checkCodeSender)
|
||||
var deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.inputCode(checkCodeSenderProxy)) }
|
||||
linkMobileProgressSubject.send(.qrScanned(checkCodeSenderProxy))
|
||||
try await deferredState.fulfill()
|
||||
|
||||
deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.sendingCode) }
|
||||
context.checkCodeInput = "01"
|
||||
context.send(viewAction: .sendCheckCode)
|
||||
try await deferredState.fulfill()
|
||||
|
||||
var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||
guard case .requestOIDCAuthorisation = action else { return false }
|
||||
return true
|
||||
}
|
||||
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
|
||||
try await deferredAction.fulfill()
|
||||
|
||||
let currentState = context.viewState.state
|
||||
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
|
||||
linkMobileProgressSubject.send(.syncingSecrets)
|
||||
try await deferredFailure.fulfill()
|
||||
|
||||
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||
guard case .dismiss = action else { return false }
|
||||
return true
|
||||
}
|
||||
linkMobileProgressSubject.send(.done)
|
||||
try await deferredAction.fulfill()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
enum Mode { case login, linkDesktop, linkMobile }
|
||||
|
||||
private func setupViewModel(mode: Mode) {
|
||||
qrLoginProgressSubject = .init(.starting)
|
||||
qrCodeLoginService = QRCodeLoginServiceMock()
|
||||
qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher()
|
||||
|
||||
linkMobileProgressSubject = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
|
||||
linkDesktopProgressSubject = .init(.starting)
|
||||
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(),
|
||||
linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher()))
|
||||
|
||||
let screenMode: QRCodeLoginScreenMode
|
||||
switch mode {
|
||||
case .login:
|
||||
screenMode = .login(qrCodeLoginService)
|
||||
case .linkDesktop:
|
||||
screenMode = .linkDesktop(linkNewDeviceService)
|
||||
case .linkMobile:
|
||||
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice())
|
||||
}
|
||||
|
||||
appMediator = AppMediatorMock.default
|
||||
viewModel = QRCodeLoginScreenViewModel(mode: screenMode,
|
||||
canSignInManually: true,
|
||||
appMediator: appMediator)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user