diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index c19aced24..b7ca2dfab 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -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") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index ab0c107df..a872f6361 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; 8E1584F8BCF407BB94F48F04 /* EncryptionResetPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreen.swift; sourceTree = ""; }; + 8E97CF050B0168F3D605F0E9 /* InviteUsersConfirmationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersConfirmationSheetView.swift; sourceTree = ""; }; 8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientSDKMock.swift; sourceTree = ""; }; 8F062DD2CCD95DC33528A16F /* KnockRequestProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestProxy.swift; sourceTree = ""; }; 8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = ""; }; @@ -2921,6 +2924,7 @@ ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewDataSourceTests.swift; sourceTree = ""; }; ED0CBEAB5F796BEFBAF7BB6A /* VideoRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineView.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; + ED25719E19B205B668FDACFF /* UserToInvite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserToInvite.swift; sourceTree = ""; }; ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = ""; }; ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 450402793..b14794500 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -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."; diff --git a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift index 1ad0ae047..7928e0e0b 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift @@ -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 } diff --git a/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift index 842b38bd3..fef82b955 100644 --- a/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift @@ -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 } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 55cfabb3e..8dd86a2ec 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -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") } diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 1e82d8565..d375e5082 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -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, diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift index e7b171e56..a9cc51ab1 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift @@ -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? } @@ -76,5 +80,7 @@ struct InviteUsersScreenViewStateBindings { enum InviteUsersScreenViewAction { case cancel case proceed + case removeUnknownUsers + case confirmUnknownUsers case toggleUser(UserProfileProxy) } diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift index 33679ee52..720d622ca 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift @@ -13,6 +13,7 @@ import SwiftUI typealias InviteUsersScreenViewModelType = StateStoreViewModel 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) + } + } } } diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersConfirmationSheetView.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersConfirmationSheetView.swift new file mode 100644 index 000000000..035556989 --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersConfirmationSheetView.swift @@ -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 + } +} diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift index 07acdde26..7457e9326 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift @@ -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 } } diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift index f0bb0ba72..b93653641 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift @@ -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() { diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift index 3dd02c2e5..0ea65db72 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift @@ -79,7 +79,7 @@ struct RoomMemberDetailsScreenViewStateBindings { var ignoreUserAlert: IgnoreUserAlertItem? var alertInfo: AlertInfo? - var inviteConfirmationUser: UserProfileProxy? + var inviteConfirmationUser: UserToInvite? /// A media item that will be previewed with QuickLook. var mediaPreviewItem: MediaPreviewItem? diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift index 8baa13015..82511a6ec 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift @@ -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 = .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) diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift index 5795fd5f3..e4398d1b8 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift @@ -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) } } diff --git a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenModels.swift b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenModels.swift index 5261615dd..a640484c4 100644 --- a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenModels.swift +++ b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenModels.swift @@ -44,7 +44,7 @@ struct StartChatScreenViewStateBindings { /// Information describing the currently displayed alert. var alertInfo: AlertInfo? - var selectedUserToInvite: UserProfileProxy? + var selectedUserToInvite: UserToInvite? var isJoinRoomByAddressSheetPresented = false } diff --git a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenViewModel.swift b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenViewModel.swift index 6d7e21a3a..f7fc802e4 100644 --- a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenViewModel.swift +++ b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenViewModel.swift @@ -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() diff --git a/ElementX/Sources/Screens/StartChatScreen/View/SendInviteConfirmationView.swift b/ElementX/Sources/Screens/StartChatScreen/View/SendInviteConfirmationView.swift index 4e3309809..5f02237a4 100644 --- a/ElementX/Sources/Screens/StartChatScreen/View/SendInviteConfirmationView.swift +++ b/ElementX/Sources/Screens/StartChatScreen/View/SendInviteConfirmationView.swift @@ -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) } } diff --git a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift index 29d504cbd..3b61b65af 100644 --- a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift @@ -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) { diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift index 27d1dee5d..b8abca026 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift @@ -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() { diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift index 82bc09a4c..a1b8b7c69 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift @@ -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? - var inviteConfirmationUser: UserProfileProxy? + var inviteConfirmationUser: UserToInvite? /// A media item that will be previewed with QuickLook. var mediaPreviewItem: MediaPreviewItem? diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift index 82e98556d..595bbeafd 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift @@ -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 = .init() var actionsPublisher: AnyPublisher { @@ -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) diff --git a/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift b/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift index 38adbad39..ce17b2f4b 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift @@ -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) } } diff --git a/ElementX/Sources/Services/Room/UserToInvite.swift b/ElementX/Sources/Services/Room/UserToInvite.swift new file mode 100644 index 000000000..9253a6a9a --- /dev/null +++ b/ElementX/Sources/Services/Room/UserToInvite.swift @@ -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 + } +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index e20c0f0ad..6486b86ff 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -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. diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPad-en-GB.png new file mode 100644 index 000000000..b188cdd7a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1f7bbc91732d5bc2846a22b9c992705ba023ace7c89f038e69211010bf2830f +size 155722 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPad-pseudo.png new file mode 100644 index 000000000..cba228e88 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d873a384efccce301a79b01f5d549c39724a2b7798315ab0334eeaf2d73f947 +size 144978 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPhone-en-GB.png new file mode 100644 index 000000000..bd8be9d7b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58cfabb92619e5d5f716d5299975ca4f2a5a38ba578a241c1fa29ef226e36a42 +size 103461 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPhone-pseudo.png new file mode 100644 index 000000000..514093d76 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersConfirmationSheetView.Default-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f219f191ab96ba0dc142114ea796a0fb0f9af30cc2ce8f29b99b0bb6718d7c3 +size 96500 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPad-en-GB.png new file mode 100644 index 000000000..e66905000 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b96b85d9a091da9515212b02ecdac23b23f2f4c9e5a1ee8e9bc68378a796cb3 +size 152293 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPad-pseudo.png new file mode 100644 index 000000000..d782a2b4d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:117598d6b54d6bd68492fd14f091ff3a436ec920d4e8ac62f83ebb899a66a334 +size 156696 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPhone-en-GB.png new file mode 100644 index 000000000..3c9859c07 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62680a39445805013d14963a26abfd65d192b96fedb321f5052c53e771575e0e +size 96073 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPhone-pseudo.png new file mode 100644 index 000000000..38b96bc3c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/inviteUsersScreen.Confirm-Selected-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:507a257e16587137397a716f62f466d934416f1349b1f39de213c5e59b947beb +size 98954 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPad-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPad-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPad-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPad-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPad-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPad-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPhone-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPhone-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPhone-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPhone-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.iPhone-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Known-Identity-iPhone-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPad-en-GB.png new file mode 100644 index 000000000..35519564b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fa0aca5dc6c594440ac0992afb83cad577f002abc84b63aa753d0b5e0e4f78d +size 103351 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPad-pseudo.png new file mode 100644 index 000000000..1fcaf7938 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26fac79b2439a30f6f4a3850e2105a7b396c82b63bac87c7147e0de85210844e +size 116096 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPhone-en-GB.png new file mode 100644 index 000000000..534abbf82 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8235d8f2ce0f4f1187534209d3ab6ff7a21dcbd49f62b6ac2b8e6d19984c9af +size 61539 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPhone-pseudo.png new file mode 100644 index 000000000..1275969c4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/sendInviteConfirmationView.With-Unknown-Identity-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9deea49513c330d3605f484f16b87e525dc49ec9892980be73740ca14d69305f +size 84901 diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift index 670d9d1a0..4662281b2 100644 --- a/UnitTests/Sources/InviteUsersViewModelTests.swift +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -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, diff --git a/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift b/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift index b32c38285..5371c7464 100644 --- a/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift @@ -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) } } diff --git a/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index e762831d9..3570b4538 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -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) { diff --git a/UnitTests/Sources/UserProfileScreenViewModelTests.swift b/UnitTests/Sources/UserProfileScreenViewModelTests.swift index 06567fa68..b9c76075b 100644 --- a/UnitTests/Sources/UserProfileScreenViewModelTests.swift +++ b/UnitTests/Sources/UserProfileScreenViewModelTests.swift @@ -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() + } }