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 fe2319cada
commit 78240ebdee
45 changed files with 541 additions and 40 deletions
@@ -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 {
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)
@@ -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:
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()
@@ -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)
}
}