Prompt user when inviting users with uncached identities (#5331)
If the user attempts to invite someone (to a room or creating a DM) whose identity is not cached, we prompt them to make sure this was their intention.
This commit is contained in:
@@ -239,6 +239,10 @@ extension AccessibilityTests {
|
||||
try await performAccessibilityAudit(named: "ImageRoomTimelineView_Previews")
|
||||
}
|
||||
|
||||
func testInviteUsersConfirmationSheetView() async throws {
|
||||
try await performAccessibilityAudit(named: "InviteUsersConfirmationSheetView_Previews")
|
||||
}
|
||||
|
||||
func testInviteUsersScreenSelectedItem() async throws {
|
||||
try await performAccessibilityAudit(named: "InviteUsersScreenSelectedItem_Previews")
|
||||
}
|
||||
|
||||
@@ -432,6 +432,7 @@
|
||||
4949C8C12669D1B5E082366E /* QRCodeLoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFA9EA59D5C0DA1BFC7B3621 /* QRCodeLoginScreen.swift */; };
|
||||
49500BBA1CD65A5AE252D970 /* RoomDirectorySearchScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BB37D96C3EA18F3CE8675D /* RoomDirectorySearchScreenModels.swift */; };
|
||||
49BBEC46D523BF6A41400048 /* URLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB34956C87731AB094DB33A /* URLTests.swift */; };
|
||||
4A361E8BAFFA20B9719B47F5 /* InviteUsersConfirmationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E97CF050B0168F3D605F0E9 /* InviteUsersConfirmationSheetView.swift */; };
|
||||
4A4110369DBB79E4A314F415 /* ComposerToolbarViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0618820D26F9871A4BBB40E /* ComposerToolbarViewModelProtocol.swift */; };
|
||||
4A618590DEB72C4F186BFED4 /* UserSessionFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */; };
|
||||
4A8287E5281B44A8754BE509 /* SessionVerificationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */; };
|
||||
@@ -897,6 +898,7 @@
|
||||
964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; };
|
||||
9696ECAFB4F0C079C5C2A526 /* AppLockSetupPINScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAF8C2226A57B9AB7446B31 /* AppLockSetupPINScreenCoordinator.swift */; };
|
||||
96B3606E30F824095B1DD022 /* NetworkMonitorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */; };
|
||||
9707AF8D41667FA9B35E8953 /* UserToInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED25719E19B205B668FDACFF /* UserToInvite.swift */; };
|
||||
97189E495F0E47805D1868DB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; };
|
||||
973C48F9E4EFB808F61BE401 /* LocationRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */; };
|
||||
978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */; };
|
||||
@@ -2387,6 +2389,7 @@
|
||||
8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitorMock.swift; sourceTree = "<group>"; };
|
||||
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
|
||||
8E1584F8BCF407BB94F48F04 /* EncryptionResetPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreen.swift; sourceTree = "<group>"; };
|
||||
8E97CF050B0168F3D605F0E9 /* InviteUsersConfirmationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersConfirmationSheetView.swift; sourceTree = "<group>"; };
|
||||
8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientSDKMock.swift; sourceTree = "<group>"; };
|
||||
8F062DD2CCD95DC33528A16F /* KnockRequestProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestProxy.swift; sourceTree = "<group>"; };
|
||||
8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = "<group>"; };
|
||||
@@ -2921,6 +2924,7 @@
|
||||
ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDataSourceTests.swift; sourceTree = "<group>"; };
|
||||
ED0CBEAB5F796BEFBAF7BB6A /* VideoRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
|
||||
ED25719E19B205B668FDACFF /* UserToInvite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserToInvite.swift; sourceTree = "<group>"; };
|
||||
ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = "<group>"; };
|
||||
ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
@@ -4134,6 +4138,7 @@
|
||||
578AF9CE60816069536C0953 /* RoomRole.swift */,
|
||||
CCE06F4A71FD46C9D8CD432E /* RoomThreadListServiceProxy.swift */,
|
||||
04EB6035C1F33F25F1EBFB7D /* RoomThreadListServiceProxyProtocol.swift */,
|
||||
ED25719E19B205B668FDACFF /* UserToInvite.swift */,
|
||||
2C0F49BD446849654C0D24E0 /* RoomMember */,
|
||||
4DC0344D2EBD0AE5D71754A9 /* RoomMembershipDetails */,
|
||||
7FC3F8FA5EA765AC3B000F55 /* RoomPreview */,
|
||||
@@ -4296,6 +4301,7 @@
|
||||
493225D61FED2DA3D3B26104 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8E97CF050B0168F3D605F0E9 /* InviteUsersConfirmationSheetView.swift */,
|
||||
C2E9B841EE4878283ECDB554 /* InviteUsersScreen.swift */,
|
||||
10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */,
|
||||
);
|
||||
@@ -7325,6 +7331,7 @@
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 7AE41FCCF9D1352E2770D1F9 /* Build configuration list for PBXProject "ElementX" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
@@ -7402,7 +7409,6 @@
|
||||
C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 681566846AF307E9BA4C72C6 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
@@ -8328,6 +8334,7 @@
|
||||
B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */,
|
||||
BFEB24336DFD5F196E6F3456 /* IntentionalMentions.swift in Sources */,
|
||||
2AAB2A77F1762A2648078A30 /* InteractiveQuickLook.swift in Sources */,
|
||||
4A361E8BAFFA20B9719B47F5 /* InviteUsersConfirmationSheetView.swift in Sources */,
|
||||
C4D2BCAA54E2C62B94B24AF4 /* InviteUsersScreen.swift in Sources */,
|
||||
E27C4D1A1F8BB77CA790B403 /* InviteUsersScreenCoordinator.swift in Sources */,
|
||||
C26DB49C06C00B5DF1A991A5 /* InviteUsersScreenModels.swift in Sources */,
|
||||
@@ -9022,6 +9029,7 @@
|
||||
7E91BAC17963ED41208F489B /* UserSessionStore.swift in Sources */,
|
||||
79D57E9AE03A2DC689D14EA2 /* UserSessionStoreMock.swift in Sources */,
|
||||
AC69B6DF15FC451AB2945036 /* UserSessionStoreProtocol.swift in Sources */,
|
||||
9707AF8D41667FA9B35E8953 /* UserToInvite.swift in Sources */,
|
||||
2447FADEF13225BB6227B977 /* VerificationBadge.swift in Sources */,
|
||||
5C33976A720B64094CBC56B1 /* VideoMediaEventsTimelineView.swift in Sources */,
|
||||
F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"crypto_history_sharing_confirm_invite_dialog_content" = "You currently don’t have any chats with these contacts. Confirm inviting them to this room before continuing.";
|
||||
"crypto_history_sharing_confirm_invite_dialog_title" = "Invite new contacts to this room?";
|
||||
"crypto_history_sharing_confirm_start_chat_dialog_content" = "You currently don’t have any chats with this person. Confirm inviting them before continuing.";
|
||||
"crypto_history_sharing_confirm_start_chat_dialog_title" = "Start a chat with this new contact?";
|
||||
"soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages.";
|
||||
"soft_logout_clear_data_dialog_title" = "Clear data";
|
||||
"soft_logout_clear_data_notice" = "Warning: Your personal data (including encryption keys) is still stored on this device.\n\nClear it if you’re finished using this device, or want to sign in to another account.";
|
||||
|
||||
@@ -791,7 +791,8 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
|
||||
isPresentedModally: true,
|
||||
userSession: userSession,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
analytics: flowParameters.analytics)
|
||||
analytics: flowParameters.analytics,
|
||||
appSettings: flowParameters.appSettings)
|
||||
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -225,7 +225,8 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
|
||||
roomProxy: roomProxy,
|
||||
userSession: flowParameters.userSession,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
analytics: flowParameters.analytics)
|
||||
analytics: flowParameters.analytics,
|
||||
appSettings: flowParameters.appSettings)
|
||||
let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
@@ -286,7 +287,8 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
|
||||
isPresentedModally: false,
|
||||
userSession: flowParameters.userSession,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
analytics: flowParameters.analytics)
|
||||
analytics: flowParameters.analytics,
|
||||
appSettings: flowParameters.appSettings)
|
||||
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -10,6 +10,14 @@ import Foundation
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
internal enum UntranslatedL10n {
|
||||
/// You currently don’t have any chats with these contacts. Confirm inviting them to this room before continuing.
|
||||
internal static var cryptoHistorySharingConfirmInviteDialogContent: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_invite_dialog_content") }
|
||||
/// Invite new contacts to this room?
|
||||
internal static var cryptoHistorySharingConfirmInviteDialogTitle: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_invite_dialog_title") }
|
||||
/// You currently don’t have any chats with this person. Confirm inviting them before continuing.
|
||||
internal static var cryptoHistorySharingConfirmStartChatDialogContent: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_start_chat_dialog_content") }
|
||||
/// Start a chat with this new contact?
|
||||
internal static var cryptoHistorySharingConfirmStartChatDialogTitle: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_start_chat_dialog_title") }
|
||||
/// Clear all data currently stored on this device?
|
||||
/// Sign in again to access your account data and messages.
|
||||
internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }
|
||||
|
||||
@@ -67,6 +67,7 @@ enum TestablePreviewsDictionary {
|
||||
"IdentityConfirmedScreen_Previews" : IdentityConfirmedScreen_Previews.self,
|
||||
"ImageMediaEventsTimelineView_Previews" : ImageMediaEventsTimelineView_Previews.self,
|
||||
"ImageRoomTimelineView_Previews" : ImageRoomTimelineView_Previews.self,
|
||||
"InviteUsersConfirmationSheetView_Previews" : InviteUsersConfirmationSheetView_Previews.self,
|
||||
"InviteUsersScreenSelectedItem_Previews" : InviteUsersScreenSelectedItem_Previews.self,
|
||||
"InviteUsersScreen_Previews" : InviteUsersScreen_Previews.self,
|
||||
"JoinRoomByAddressView_Previews" : JoinRoomByAddressView_Previews.self,
|
||||
|
||||
@@ -30,6 +30,7 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
|
||||
var selectedUsers: [UserProfileProxy] = []
|
||||
var membershipState: [String: MembershipState] = .init()
|
||||
var usersToConfirm: [UserProfileProxy] = []
|
||||
|
||||
var isSearching = false
|
||||
|
||||
@@ -69,6 +70,9 @@ struct InviteUsersScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
var selectedUsersPosition: String?
|
||||
|
||||
/// Whether we are showing the confirmation dialog.
|
||||
var presentConfirmationDialog = false
|
||||
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<InviteUsersScreenErrorType>?
|
||||
}
|
||||
@@ -76,5 +80,7 @@ struct InviteUsersScreenViewStateBindings {
|
||||
enum InviteUsersScreenViewAction {
|
||||
case cancel
|
||||
case proceed
|
||||
case removeUnknownUsers
|
||||
case confirmUnknownUsers
|
||||
case toggleUser(UserProfileProxy)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import SwiftUI
|
||||
typealias InviteUsersScreenViewModelType = StateStoreViewModel<InviteUsersScreenViewState, InviteUsersScreenViewAction>
|
||||
|
||||
class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
@@ -31,6 +32,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
userDiscoveryService: UserDiscoveryServiceProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appSettings: AppSettings) {
|
||||
clientProxy = userSession.clientProxy
|
||||
self.roomProxy = roomProxy
|
||||
self.userDiscoveryService = userDiscoveryService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
@@ -59,6 +61,23 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .proceed:
|
||||
guard appSettings.enableKeyShareOnInvite,
|
||||
roomProxy.details.historySharingState != RoomHistorySharingState.hidden,
|
||||
!state.usersToConfirm.isEmpty,
|
||||
!state.isSkippable else {
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
return
|
||||
}
|
||||
state.bindings.presentConfirmationDialog = true
|
||||
case .removeUnknownUsers:
|
||||
state.bindings.presentConfirmationDialog = false
|
||||
state.selectedUsers.removeAll { user in
|
||||
state.usersToConfirm.contains { $0.userID == user.userID }
|
||||
}
|
||||
state.usersToConfirm = []
|
||||
case .confirmUnknownUsers:
|
||||
state.bindings.presentConfirmationDialog = false
|
||||
state.usersToConfirm = []
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
case .toggleUser(let user):
|
||||
toggleUser(user)
|
||||
@@ -73,6 +92,17 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
} else {
|
||||
state.selectedUsers.append(user)
|
||||
withElementAnimation(.easeInOut) { state.bindings.selectedUsersPosition = user.userID }
|
||||
Task {
|
||||
let identityUnknown = if case .success(let identity) = await self.clientProxy.userIdentity(for: user.userID, fallBackToServer: false) {
|
||||
identity == nil
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if identityUnknown {
|
||||
// If we do not have the identity cached, we will prompt the user to confirm they meant to invite them.
|
||||
self.state.usersToConfirm.append(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// Copyright 2026 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 Compound
|
||||
import SwiftUI
|
||||
|
||||
struct InviteUsersConfirmationSheetView: View {
|
||||
@ObservedObject var context: InviteUsersScreenViewModel.Context
|
||||
|
||||
/// The users whose identities we wish the user to confirm.
|
||||
var users: [UserProfileProxy]
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
||||
VStack(spacing: 32) {
|
||||
TitleAndIcon(title: L10n.screenInviteUsersConfirmDialogTitle(users.count),
|
||||
subtitle: L10n.screenInviteUsersConfirmDialogSubtitle(users.count),
|
||||
icon: \.userAddSolid,
|
||||
iconStyle: .defaultSolid)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(users, id: \.userID) { user in
|
||||
UserProfileListRow(user: user,
|
||||
membership: nil,
|
||||
mediaProvider: context.mediaProvider,
|
||||
kind: .label)
|
||||
.rowDivider(alignment: .top)
|
||||
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.userProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
} bottomContent: {
|
||||
HStack(spacing: 32) {
|
||||
Button(L10n.actionRemove, role: .cancel) {
|
||||
context.send(viewAction: .removeUnknownUsers)
|
||||
}
|
||||
.buttonStyle(.compound(.secondary))
|
||||
|
||||
Button(L10n.actionInvite) {
|
||||
context.send(viewAction: .confirmUnknownUsers)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
}
|
||||
}
|
||||
.background()
|
||||
.backgroundStyle(.compound.bgCanvasDefault)
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
struct InviteUsersConfirmationSheetView_Previews: PreviewProvider, TestablePreview {
|
||||
static var viewModel = makeViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
InviteUsersConfirmationSheetView(context: viewModel.context, users: [.mockAlice, .mockCharlie, .mockBob, .mockDan])
|
||||
.previewDisplayName("Default")
|
||||
}
|
||||
|
||||
static func makeViewModel() -> InviteUsersScreenViewModel {
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
|
||||
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: UserDiscoveryServiceMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
viewModel.state.usersToConfirm = [.mockAlice, .mockCharlie, .mockBob, .mockDan]
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ struct InviteUsersScreen: View {
|
||||
disablesInteractiveDismiss: true,
|
||||
accessibilityFocusOnStart: true)
|
||||
.compoundSearchField()
|
||||
.sheet(isPresented: $context.presentConfirmationDialog) {
|
||||
InviteUsersConfirmationSheetView(context: context, users: context.viewState.usersToConfirm)
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
.navigationBarBackButtonHidden(context.viewState.isSkippable)
|
||||
}
|
||||
@@ -149,6 +152,7 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
static let searchingViewModel = makeViewModel(searchQuery: "Alice")
|
||||
static let selectedViewModel = makeViewModel(hasSelection: true)
|
||||
static let confirmSelectedViewModel = makeViewModel(shouldConfirm: true)
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
@@ -170,9 +174,14 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
}
|
||||
.previewDisplayName("Selected")
|
||||
.snapshotPreferences(expect: selectedViewModel.context.$viewState.map { !$0.selectedUsers.isEmpty })
|
||||
|
||||
ElementNavigationStack {
|
||||
InviteUsersScreen(context: confirmSelectedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Confirm Selected")
|
||||
}
|
||||
|
||||
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false) -> InviteUsersScreenViewModel {
|
||||
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false, shouldConfirm: Bool = false) -> InviteUsersScreenViewModel {
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose]
|
||||
|
||||
@@ -194,6 +203,11 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
viewModel.state.selectedUsers = [.mockAlice]
|
||||
}
|
||||
|
||||
if shouldConfirm {
|
||||
viewModel.state.usersToConfirm = [.mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice]
|
||||
viewModel.state.bindings.presentConfirmationDialog = true
|
||||
}
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct RoomMemberDetailsScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let analytics: AnalyticsService
|
||||
let appSettings: AppSettings
|
||||
}
|
||||
|
||||
enum RoomMemberDetailsScreenCoordinatorAction {
|
||||
@@ -39,7 +40,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol {
|
||||
roomProxy: parameters.roomProxy,
|
||||
userSession: parameters.userSession,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
analytics: parameters.analytics)
|
||||
analytics: parameters.analytics,
|
||||
appSettings: parameters.appSettings)
|
||||
}
|
||||
|
||||
func start() {
|
||||
|
||||
@@ -79,7 +79,7 @@ struct RoomMemberDetailsScreenViewStateBindings {
|
||||
|
||||
var ignoreUserAlert: IgnoreUserAlertItem?
|
||||
var alertInfo: AlertInfo<RoomMemberDetailsScreenAlertType>?
|
||||
var inviteConfirmationUser: UserProfileProxy?
|
||||
var inviteConfirmationUser: UserToInvite?
|
||||
|
||||
/// A media item that will be previewed with QuickLook.
|
||||
var mediaPreviewItem: MediaPreviewItem?
|
||||
|
||||
@@ -16,6 +16,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
||||
private let userSession: UserSessionProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let analytics: AnalyticsService
|
||||
private let appSettings: AppSettings
|
||||
|
||||
private var actionsSubject: PassthroughSubject<RoomMemberDetailsScreenViewModelAction, Never> = .init()
|
||||
|
||||
@@ -29,11 +30,13 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
||||
roomProxy: JoinedRoomProxyProtocol,
|
||||
userSession: UserSessionProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
analytics: AnalyticsService) {
|
||||
analytics: AnalyticsService,
|
||||
appSettings: AppSettings) {
|
||||
self.roomProxy = roomProxy
|
||||
self.userSession = userSession
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.analytics = analytics
|
||||
self.appSettings = appSettings
|
||||
|
||||
let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, bindings: .init())
|
||||
|
||||
@@ -197,8 +200,19 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
||||
case .success(let roomID):
|
||||
if let roomID {
|
||||
actionsSubject.send(.openDirectChat(roomID: roomID))
|
||||
} else if appSettings.enableKeyShareOnInvite, roomProxy.details.historySharingState != RoomHistorySharingState.hidden {
|
||||
Task {
|
||||
let identity = await self.userSession.clientProxy.userIdentity(for: roomMemberProxy.userID, fallBackToServer: false)
|
||||
let user: UserProfileProxy = .init(userID: roomMemberProxy.userID, displayName: roomMemberProxy.displayName, avatarURL: roomMemberProxy.avatarURL)
|
||||
let isUnknown = if case .success(let identity) = identity {
|
||||
identity == nil
|
||||
} else {
|
||||
state.bindings.inviteConfirmationUser = .init(userID: roomMemberProxy.userID, displayName: roomMemberProxy.displayName, avatarURL: roomMemberProxy.avatarURL)
|
||||
true
|
||||
}
|
||||
self.state.bindings.inviteConfirmationUser = .init(user: user, isUnknown: isUnknown)
|
||||
}
|
||||
} else {
|
||||
state.bindings.inviteConfirmationUser = .init(user: .init(userID: roomMemberProxy.userID, displayName: roomMemberProxy.displayName, avatarURL: roomMemberProxy.avatarURL), isUnknown: false)
|
||||
}
|
||||
case .failure:
|
||||
state.bindings.alertInfo = .init(id: .failedOpeningDirectChat)
|
||||
|
||||
@@ -240,6 +240,7 @@ struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
roomProxy: roomProxyMock,
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ struct StartChatScreenViewStateBindings {
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<StartChatScreenErrorType>?
|
||||
|
||||
var selectedUserToInvite: UserProfileProxy?
|
||||
var selectedUserToInvite: UserToInvite?
|
||||
var isJoinRoomByAddressSheetPresented = false
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,21 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
|
||||
hideLoadingIndicator()
|
||||
actionsSubject.send(.showRoom(roomID: roomId))
|
||||
case .success:
|
||||
if appSettings.enableKeyShareOnInvite {
|
||||
Task {
|
||||
// If an error occured while fetching the identity, assume they are unknown.
|
||||
let isUnknown = if case .success(let identity) = await self.userSession.clientProxy.userIdentity(for: user.userID, fallBackToServer: false) {
|
||||
identity == nil
|
||||
} else {
|
||||
true
|
||||
}
|
||||
self.state.bindings.selectedUserToInvite = UserToInvite(user: user, isUnknown: isUnknown)
|
||||
hideLoadingIndicator()
|
||||
state.bindings.selectedUserToInvite = user
|
||||
}
|
||||
} else {
|
||||
hideLoadingIndicator()
|
||||
state.bindings.selectedUserToInvite = UserToInvite(user: user, isUnknown: false)
|
||||
}
|
||||
case .failure:
|
||||
hideLoadingIndicator()
|
||||
displayError()
|
||||
|
||||
@@ -10,7 +10,7 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct SendInviteConfirmationView: View {
|
||||
let userToInvite: UserProfileProxy
|
||||
let userToInvite: UserToInvite
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let onInvite: () -> Void
|
||||
|
||||
@@ -19,14 +19,26 @@ struct SendInviteConfirmationView: View {
|
||||
@State private var sheetHeight: CGFloat = .zero
|
||||
private let topPadding: CGFloat = 24
|
||||
|
||||
private var title: String {
|
||||
if userToInvite.isUnknown {
|
||||
L10n.screenBottomSheetCreateDmUnknownUserTitle
|
||||
} else {
|
||||
L10n.screenBottomSheetCreateDmTitle
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let string: String
|
||||
if let displayName = userToInvite.displayName {
|
||||
string = L10n.commonNameAndId(displayName, userToInvite.userID)
|
||||
string = L10n.commonNameAndId(displayName, userToInvite.id)
|
||||
} else {
|
||||
string = userToInvite.userID
|
||||
string = userToInvite.id
|
||||
}
|
||||
return if userToInvite.isUnknown {
|
||||
L10n.screenBottomSheetCreateDmUnknownUserContent
|
||||
} else {
|
||||
L10n.screenBottomSheetCreateDmMessage(string)
|
||||
}
|
||||
return L10n.screenBottomSheetCreateDmMessage(string)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -48,11 +60,11 @@ struct SendInviteConfirmationView: View {
|
||||
VStack(spacing: 16) {
|
||||
LoadableAvatarImage(url: userToInvite.avatarURL,
|
||||
name: userToInvite.displayName,
|
||||
contentID: userToInvite.userID,
|
||||
contentID: userToInvite.id,
|
||||
avatarSize: .user(on: .sendInviteConfirmation),
|
||||
mediaProvider: mediaProvider)
|
||||
VStack(spacing: 8) {
|
||||
Text(L10n.screenBottomSheetCreateDmTitle)
|
||||
Text(title)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.compound.headingMDBold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
@@ -91,7 +103,22 @@ struct SendInviteConfirmationView: View {
|
||||
|
||||
struct SendInviteConfirmationView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
SendInviteConfirmationView(userToInvite: .mockBob,
|
||||
SendInviteConfirmationView(userToInvite: .mockKnownBob,
|
||||
mediaProvider: nil) { }
|
||||
.previewDisplayName("With Known Identity")
|
||||
|
||||
SendInviteConfirmationView(userToInvite: .mockUnknownBob,
|
||||
mediaProvider: nil) { }
|
||||
.previewDisplayName("With Unknown Identity")
|
||||
}
|
||||
}
|
||||
|
||||
private extension UserToInvite {
|
||||
static var mockKnownBob: Self {
|
||||
.init(user: .mockBob, isUnknown: false)
|
||||
}
|
||||
|
||||
static var mockUnknownBob: Self {
|
||||
.init(user: .mockBob, isUnknown: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ struct StartChatScreen: View {
|
||||
disablesInteractiveDismiss: true)
|
||||
.compoundSearchField()
|
||||
.alert(item: $context.alertInfo)
|
||||
.sheet(item: $context.selectedUserToInvite) { user in
|
||||
SendInviteConfirmationView(userToInvite: user, mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .createDM(user: user))
|
||||
.sheet(item: $context.selectedUserToInvite) { userToInvite in
|
||||
SendInviteConfirmationView(userToInvite: userToInvite,
|
||||
mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .createDM(user: userToInvite.user))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $context.isJoinRoomByAddressSheetPresented) {
|
||||
|
||||
@@ -15,6 +15,7 @@ struct UserProfileScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let analytics: AnalyticsService
|
||||
let appSettings: AppSettings
|
||||
}
|
||||
|
||||
enum UserProfileScreenCoordinatorAction {
|
||||
@@ -38,7 +39,8 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol {
|
||||
isPresentedModally: parameters.isPresentedModally,
|
||||
userSession: parameters.userSession,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
analytics: parameters.analytics)
|
||||
analytics: parameters.analytics,
|
||||
appSettings: parameters.appSettings)
|
||||
}
|
||||
|
||||
func start() {
|
||||
|
||||
@@ -20,6 +20,7 @@ struct UserProfileScreenViewState: BindableState {
|
||||
let isPresentedModally: Bool
|
||||
|
||||
var userProfile: UserProfileProxy?
|
||||
var isIdentityKnown = false
|
||||
var isVerified: Bool?
|
||||
var permalink: URL?
|
||||
var dmRoomID: String?
|
||||
@@ -33,7 +34,7 @@ struct UserProfileScreenViewState: BindableState {
|
||||
|
||||
struct UserProfileScreenViewStateBindings {
|
||||
var alertInfo: AlertInfo<UserProfileScreenAlertType>?
|
||||
var inviteConfirmationUser: UserProfileProxy?
|
||||
var inviteConfirmationUser: UserToInvite?
|
||||
|
||||
/// A media item that will be previewed with QuickLook.
|
||||
var mediaPreviewItem: MediaPreviewItem?
|
||||
|
||||
@@ -16,6 +16,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
private let userSession: UserSessionProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let analytics: AnalyticsService
|
||||
private let appSettings: AppSettings
|
||||
|
||||
private var actionsSubject: PassthroughSubject<UserProfileScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<UserProfileScreenViewModelAction, Never> {
|
||||
@@ -26,10 +27,12 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
isPresentedModally: Bool,
|
||||
userSession: UserSessionProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
analytics: AnalyticsService) {
|
||||
analytics: AnalyticsService,
|
||||
appSettings: AppSettings) {
|
||||
self.userSession = userSession
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.analytics = analytics
|
||||
self.appSettings = appSettings
|
||||
|
||||
let initialViewState = UserProfileScreenViewState(userID: userID,
|
||||
isOwnUser: userID == userSession.clientProxy.userID,
|
||||
@@ -92,8 +95,10 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
}
|
||||
|
||||
if case let .success(.some(identity)) = await identityResult {
|
||||
state.isIdentityKnown = true
|
||||
state.isVerified = identity.verificationState == .verified
|
||||
} else {
|
||||
state.isIdentityKnown = false
|
||||
MXLog.error("Failed to find the user's identity.")
|
||||
}
|
||||
}
|
||||
@@ -122,7 +127,18 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
if let roomID {
|
||||
actionsSubject.send(.openDirectChat(roomID: roomID))
|
||||
} else {
|
||||
state.bindings.inviteConfirmationUser = userProfile
|
||||
if appSettings.enableKeyShareOnInvite {
|
||||
Task {
|
||||
let isUnknown = if case let .success(identity) = await userSession.clientProxy.userIdentity(for: userProfile.userID, fallBackToServer: false) {
|
||||
identity == nil
|
||||
} else {
|
||||
true
|
||||
}
|
||||
state.bindings.inviteConfirmationUser = .init(user: userProfile, isUnknown: isUnknown)
|
||||
}
|
||||
} else {
|
||||
state.bindings.inviteConfirmationUser = .init(user: userProfile, isUnknown: false)
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
state.bindings.alertInfo = .init(id: .failedOpeningDirectChat)
|
||||
|
||||
@@ -21,8 +21,8 @@ struct UserProfileScreen: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
.sheet(item: $context.inviteConfirmationUser) { user in
|
||||
SendInviteConfirmationView(userToInvite: user,
|
||||
.sheet(item: $context.inviteConfirmationUser) { userToInvite in
|
||||
SendInviteConfirmationView(userToInvite: userToInvite,
|
||||
mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .createDirectChat)
|
||||
}
|
||||
@@ -147,6 +147,7 @@ struct UserProfileScreen_Previews: PreviewProvider, TestablePreview {
|
||||
isPresentedModally: false,
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
}
|
||||
|
||||
31
ElementX/Sources/Services/Room/UserToInvite.swift
Normal file
31
ElementX/Sources/Services/Room/UserToInvite.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Copyright 2026 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
|
||||
|
||||
struct UserToInvite: Identifiable {
|
||||
/// The profile of the user being invited.
|
||||
var user: UserProfileProxy
|
||||
|
||||
/// The ID of the user being invited.
|
||||
var id: String {
|
||||
user.id
|
||||
}
|
||||
|
||||
/// Whether we have the cryptographic identity of this user cached locally.
|
||||
var isUnknown: Bool
|
||||
|
||||
/// The display name of the user being invited
|
||||
var displayName: String? {
|
||||
user.displayName
|
||||
}
|
||||
|
||||
/// The avatar URL of the user's profile, if available.
|
||||
var avatarURL: URL? {
|
||||
user.avatarURL
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,14 @@ extension PreviewTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func inviteUsersConfirmationSheetView() async throws {
|
||||
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
|
||||
for (index, preview) in InviteUsersConfirmationSheetView_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func inviteUsersScreenSelectedItem() async throws {
|
||||
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b1f7bbc91732d5bc2846a22b9c992705ba023ace7c89f038e69211010bf2830f
|
||||
size 155722
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1d873a384efccce301a79b01f5d549c39724a2b7798315ab0334eeaf2d73f947
|
||||
size 144978
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58cfabb92619e5d5f716d5299975ca4f2a5a38ba578a241c1fa29ef226e36a42
|
||||
size 103461
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f219f191ab96ba0dc142114ea796a0fb0f9af30cc2ce8f29b99b0bb6718d7c3
|
||||
size 96500
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62680a39445805013d14963a26abfd65d192b96fedb321f5052c53e771575e0e
|
||||
size 96073
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:507a257e16587137397a716f62f466d934416f1349b1f39de213c5e59b947beb
|
||||
size 98954
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1fa0aca5dc6c594440ac0992afb83cad577f002abc84b63aa753d0b5e0e4f78d
|
||||
size 103351
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:26fac79b2439a30f6f4a3850e2105a7b396c82b63bac87c7147e0de85210844e
|
||||
size 116096
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8235d8f2ce0f4f1187534209d3ab6ff7a21dcbd49f62b6ac2b8e6d19984c9af
|
||||
size 61539
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9deea49513c330d3605f484f16b87e525dc49ec9892980be73740ca14d69305f
|
||||
size 84901
|
||||
@@ -11,16 +11,28 @@ import Combine
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
struct InviteUsersScreenViewModelTests {
|
||||
final class InviteUsersScreenViewModelTests {
|
||||
var viewModel: InviteUsersScreenViewModelProtocol!
|
||||
var userDiscoveryService: UserDiscoveryServiceMock!
|
||||
var clientProxy: ClientProxyMock!
|
||||
var appSettings: AppSettings!
|
||||
|
||||
init() {
|
||||
AppSettings.resetAllSettings()
|
||||
appSettings = AppSettings()
|
||||
ServiceLocator.shared.register(appSettings: appSettings)
|
||||
}
|
||||
|
||||
deinit {
|
||||
AppSettings.resetAllSettings()
|
||||
}
|
||||
|
||||
var context: InviteUsersScreenViewModel.Context {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func selectUser() {
|
||||
func selectUser() {
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "newroom", members: []))
|
||||
roomProxy.inviteUserIDReturnValue = .success(())
|
||||
setupViewModel(roomProxy: roomProxy, isSkippable: true)
|
||||
@@ -32,7 +44,7 @@ struct InviteUsersScreenViewModelTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func reselectUser() {
|
||||
func reselectUser() {
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "newroom", members: []))
|
||||
roomProxy.inviteUserIDReturnValue = .success(())
|
||||
setupViewModel(roomProxy: roomProxy, isSkippable: true)
|
||||
@@ -46,7 +58,7 @@ struct InviteUsersScreenViewModelTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func deselectUser() {
|
||||
func deselectUser() {
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "newroom", members: []))
|
||||
roomProxy.inviteUserIDReturnValue = .success(())
|
||||
setupViewModel(roomProxy: roomProxy, isSkippable: true)
|
||||
@@ -60,7 +72,7 @@ struct InviteUsersScreenViewModelTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func inviteButton() async throws {
|
||||
func inviteButton() async throws {
|
||||
let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob]
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers))
|
||||
roomProxy.inviteUserIDReturnValue = .success(())
|
||||
@@ -87,10 +99,82 @@ struct InviteUsersScreenViewModelTests {
|
||||
#expect(roomProxy.inviteUserIDReceivedInvocations == [RoomMemberProxyMock.mockAlice.userID])
|
||||
}
|
||||
|
||||
private mutating func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) {
|
||||
// MARK: - History Sharing
|
||||
|
||||
@Test
|
||||
func invitingUnknownUsersOpensConfirmationDialog() async throws {
|
||||
appSettings.enableKeyShareOnInvite = true
|
||||
|
||||
let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob]
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers))
|
||||
roomProxy.inviteUserIDReturnValue = .success(())
|
||||
setupViewModel(roomProxy: roomProxy, isSkippable: false)
|
||||
|
||||
// Mock the lack of cached user identity
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .success(nil)
|
||||
|
||||
let deferredState = deferFulfillment(viewModel.context.$viewState) { state in
|
||||
state.isUserSelected(.mockAlice) && state.usersToConfirm.contains(.mockAlice)
|
||||
}
|
||||
|
||||
context.send(viewAction: .toggleUser(.mockAlice))
|
||||
try await deferredState.fulfill()
|
||||
|
||||
context.send(viewAction: .proceed)
|
||||
#expect(context.presentConfirmationDialog)
|
||||
|
||||
let deferredAction = deferFulfillment(viewModel.actions) { action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
context.send(viewAction: .confirmUnknownUsers)
|
||||
|
||||
try await deferredAction.fulfill()
|
||||
#expect(roomProxy.inviteUserIDReceivedInvocations == [RoomMemberProxyMock.mockAlice.userID])
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeButtonRemovesUnknownUsers() async throws {
|
||||
appSettings.enableKeyShareOnInvite = true
|
||||
|
||||
let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob]
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers))
|
||||
roomProxy.inviteUserIDReturnValue = .success(())
|
||||
setupViewModel(roomProxy: roomProxy, isSkippable: false)
|
||||
|
||||
// Mock the lack of cached user identity
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .success(nil)
|
||||
|
||||
var deferredState = deferFulfillment(viewModel.context.$viewState) { state in
|
||||
state.isUserSelected(.mockAlice) && state.usersToConfirm.contains(.mockAlice)
|
||||
}
|
||||
|
||||
context.send(viewAction: .toggleUser(.mockAlice))
|
||||
try await deferredState.fulfill()
|
||||
|
||||
context.send(viewAction: .proceed)
|
||||
#expect(context.presentConfirmationDialog)
|
||||
|
||||
deferredState = deferFulfillment(viewModel.context.$viewState) { state in
|
||||
!state.usersToConfirm.contains(.mockAlice) && !state.selectedUsers.contains(.mockAlice)
|
||||
}
|
||||
|
||||
context.send(viewAction: .removeUnknownUsers)
|
||||
try await deferredState.fulfill()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) {
|
||||
userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([])
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init()),
|
||||
|
||||
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
|
||||
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
roomProxy: roomProxy,
|
||||
isSkippable: isSkippable,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
|
||||
@@ -11,6 +11,7 @@ import Testing
|
||||
|
||||
@MainActor
|
||||
struct RoomMemberDetailsViewModelTests {
|
||||
var clientProxy: ClientProxy!
|
||||
var viewModel: RoomMemberDetailsScreenViewModelProtocol!
|
||||
var roomProxyMock: JoinedRoomProxyMock!
|
||||
var roomMemberProxyMock: RoomMemberProxyMock!
|
||||
@@ -152,6 +153,52 @@ struct RoomMemberDetailsViewModelTests {
|
||||
#expect(context.alertInfo == nil)
|
||||
}
|
||||
|
||||
// MARK: - History Sharing
|
||||
|
||||
@Test
|
||||
mutating func inviteConfirmationFetchesIdentity() async throws {
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
setup(roomMemberProxyMock: .mockBob, clientProxy: clientProxy)
|
||||
|
||||
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
|
||||
try await waitForMemberToLoad.fulfill()
|
||||
|
||||
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
|
||||
clientProxy.directRoomForUserIDReturnValue = .success(nil)
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .success(UserIdentityProxyMock(configuration: .init(verificationState: .notVerified)))
|
||||
|
||||
// The user identity becomes known, i.e. not unknown.
|
||||
let deferred = deferFulfillment(viewModel.context.$viewState.compactMap(\.bindings.inviteConfirmationUser)) {
|
||||
!$0.isUnknown
|
||||
}
|
||||
context.send(viewAction: .openDirectChat)
|
||||
try await deferred.fulfill()
|
||||
|
||||
#expect(clientProxy.userIdentityForFallBackToServerCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func inviteConfirmationFallsBackToUnknownIdentityOnFailure() async throws {
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
setup(roomMemberProxyMock: .mockBob, clientProxy: clientProxy)
|
||||
|
||||
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
|
||||
try await waitForMemberToLoad.fulfill()
|
||||
|
||||
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
|
||||
clientProxy.directRoomForUserIDReturnValue = .success(nil)
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .failure(.forbiddenAccess)
|
||||
|
||||
// The user identity is always unknown, i.e. never not unknown.
|
||||
let deferred = deferFailure(viewModel.context.$viewState.compactMap(\.bindings.inviteConfirmationUser), timeout: .seconds(5)) {
|
||||
!$0.isUnknown
|
||||
}
|
||||
context.send(viewAction: .openDirectChat)
|
||||
try await deferred.fulfill()
|
||||
|
||||
#expect(clientProxy.userIdentityForFallBackToServerCalled)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private mutating func setup(roomMemberProxyMock: RoomMemberProxyMock, clientProxy: ClientProxyMock? = nil) {
|
||||
@@ -166,6 +213,7 @@ struct RoomMemberDetailsViewModelTests {
|
||||
roomProxy: roomProxyMock,
|
||||
userSession: userSession,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,40 @@ struct StartChatScreenViewModelTests {
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
// MARK: - History Sharing
|
||||
|
||||
@Test
|
||||
func inviteConfirmationFetchesIdentity() async throws {
|
||||
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
|
||||
clientProxy.directRoomForUserIDReturnValue = .success(nil)
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .success(UserIdentityProxyMock(configuration: .init(verificationState: .notVerified)))
|
||||
|
||||
// User identity becomes known, i.e. not unknown
|
||||
let deferred = deferFulfillment(viewModel.context.$viewState.compactMap(\.bindings.selectedUserToInvite)) {
|
||||
!$0.isUnknown
|
||||
}
|
||||
context.send(viewAction: .selectUser(.mockBob))
|
||||
try await deferred.fulfill()
|
||||
|
||||
#expect(clientProxy.userIdentityForFallBackToServerCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
func inviteConfirmationFallsBackToUnknownIdentityOnFailure() async throws {
|
||||
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
|
||||
clientProxy.directRoomForUserIDReturnValue = .success(nil)
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .failure(.forbiddenAccess)
|
||||
|
||||
// User identity never becomes known, i.e. is never not unknown
|
||||
let deferred = deferFailure(viewModel.context.$viewState.compactMap(\.bindings.selectedUserToInvite), timeout: .seconds(5)) {
|
||||
!$0.isUnknown
|
||||
}
|
||||
context.send(viewAction: .selectUser(.mockBob))
|
||||
try await deferred.fulfill()
|
||||
|
||||
#expect(clientProxy.userIdentityForFallBackToServerCalled)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func assertSearchResults(toBe count: Int) {
|
||||
|
||||
@@ -21,7 +21,8 @@ struct UserProfileScreenViewModelTests {
|
||||
isPresentedModally: false,
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
let context = viewModel.context
|
||||
|
||||
let waitForMemberToLoad = deferFulfillment(context.observe(\.viewState.userProfile)) { $0 != nil }
|
||||
@@ -42,7 +43,8 @@ struct UserProfileScreenViewModelTests {
|
||||
isPresentedModally: false,
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
let context = viewModel.context
|
||||
|
||||
let waitForMemberToLoad = deferFulfillment(context.observe(\.viewState.userProfile)) { $0 != nil }
|
||||
@@ -52,4 +54,32 @@ struct UserProfileScreenViewModelTests {
|
||||
#expect(context.viewState.userProfile == profile)
|
||||
#expect(context.viewState.permalink != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func startingDmWithUnknownUserFetchesIdentity() async throws {
|
||||
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
|
||||
|
||||
let profile = UserProfileProxy.mockAlice
|
||||
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.directRoomForUserIDReturnValue = .success(nil)
|
||||
clientProxy.userIdentityForFallBackToServerReturnValue = .success(nil)
|
||||
|
||||
let viewModel = UserProfileScreenViewModel(userID: profile.userID,
|
||||
isPresentedModally: false,
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
let context = viewModel.context
|
||||
|
||||
let waitForMemberToLoad = deferFulfillment(context.observe(\.viewState.userProfile)) { $0 != nil }
|
||||
try await waitForMemberToLoad.fulfill()
|
||||
|
||||
let deferred = deferFulfillment(context.observe(\.viewState.bindings).compactMap(\.inviteConfirmationUser), timeout: .seconds(5)) { $0.isUnknown }
|
||||
|
||||
context.send(viewAction: .openDirectChat)
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user