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:
Skye Elliot
2026-04-14 12:51:58 +01:00
committed by GitHub
parent 2daf23fd02
commit 477bf859c5
45 changed files with 541 additions and 40 deletions

View File

@@ -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")
}

View File

@@ -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 */,

View File

@@ -1,3 +1,7 @@
"crypto_history_sharing_confirm_invite_dialog_content" = "You currently dont 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 dont 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 youre finished using this device, or want to sign in to another account.";

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 dont 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 dont 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") }

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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() {

View File

@@ -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?

View File

@@ -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 {
true
}
self.state.bindings.inviteConfirmationUser = .init(user: user, isUnknown: isUnknown)
}
} else {
state.bindings.inviteConfirmationUser = .init(userID: roomMemberProxy.userID, displayName: roomMemberProxy.displayName, avatarURL: roomMemberProxy.avatarURL)
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)

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -67,8 +67,21 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
hideLoadingIndicator()
actionsSubject.send(.showRoom(roomID: roomId))
case .success:
hideLoadingIndicator()
state.bindings.selectedUserToInvite = user
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()
}
} else {
hideLoadingIndicator()
state.bindings.selectedUserToInvite = UserToInvite(user: user, isUnknown: false)
}
case .failure:
hideLoadingIndicator()
displayError()

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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?

View File

@@ -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)

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1f7bbc91732d5bc2846a22b9c992705ba023ace7c89f038e69211010bf2830f
size 155722

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d873a384efccce301a79b01f5d549c39724a2b7798315ab0334eeaf2d73f947
size 144978

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58cfabb92619e5d5f716d5299975ca4f2a5a38ba578a241c1fa29ef226e36a42
size 103461

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f219f191ab96ba0dc142114ea796a0fb0f9af30cc2ce8f29b99b0bb6718d7c3
size 96500

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62680a39445805013d14963a26abfd65d192b96fedb321f5052c53e771575e0e
size 96073

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:507a257e16587137397a716f62f466d934416f1349b1f39de213c5e59b947beb
size 98954

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1fa0aca5dc6c594440ac0992afb83cad577f002abc84b63aa753d0b5e0e4f78d
size 103351

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:26fac79b2439a30f6f4a3850e2105a7b396c82b63bac87c7147e0de85210844e
size 116096

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8235d8f2ce0f4f1187534209d3ab6ff7a21dcbd49f62b6ac2b8e6d19984c9af
size 61539

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9deea49513c330d3605f484f16b87e525dc49ec9892980be73740ca14d69305f
size 84901

View File

@@ -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,

View File

@@ -11,6 +11,7 @@ import Testing
@MainActor
struct RoomMemberDetailsViewModelTests {
var clientProxy: ClientProxy!
var viewModel: RoomMemberDetailsScreenViewModelProtocol!
var roomProxyMock: JoinedRoomProxyMock!
var roomMemberProxyMock: RoomMemberProxyMock!
@@ -151,6 +152,52 @@ struct RoomMemberDetailsViewModelTests {
#expect(context.ignoreUserAlert == nil)
#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
@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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()
}
}