mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+74
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user