diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 13e6c52bb8..55dcff2dc5 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4682,6 +4682,76 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { return pinUserIdentityReturnValue } } + //MARK: - withdrawUserIdentityVerification + + var withdrawUserIdentityVerificationUnderlyingCallsCount = 0 + var withdrawUserIdentityVerificationCallsCount: Int { + get { + if Thread.isMainThread { + return withdrawUserIdentityVerificationUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = withdrawUserIdentityVerificationUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + withdrawUserIdentityVerificationUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + withdrawUserIdentityVerificationUnderlyingCallsCount = newValue + } + } + } + } + var withdrawUserIdentityVerificationCalled: Bool { + return withdrawUserIdentityVerificationCallsCount > 0 + } + var withdrawUserIdentityVerificationReceivedUserID: String? + var withdrawUserIdentityVerificationReceivedInvocations: [String] = [] + + var withdrawUserIdentityVerificationUnderlyingReturnValue: Result! + var withdrawUserIdentityVerificationReturnValue: Result! { + get { + if Thread.isMainThread { + return withdrawUserIdentityVerificationUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = withdrawUserIdentityVerificationUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + withdrawUserIdentityVerificationUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + withdrawUserIdentityVerificationUnderlyingReturnValue = newValue + } + } + } + } + var withdrawUserIdentityVerificationClosure: ((String) async -> Result)? + + func withdrawUserIdentityVerification(_ userID: String) async -> Result { + withdrawUserIdentityVerificationCallsCount += 1 + withdrawUserIdentityVerificationReceivedUserID = userID + DispatchQueue.main.async { + self.withdrawUserIdentityVerificationReceivedInvocations.append(userID) + } + if let withdrawUserIdentityVerificationClosure = withdrawUserIdentityVerificationClosure { + return await withdrawUserIdentityVerificationClosure(userID) + } else { + return withdrawUserIdentityVerificationReturnValue + } + } //MARK: - resetIdentity var resetIdentityUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift index 1c9bcbd8c5..b886860079 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift @@ -66,6 +66,8 @@ enum ComposerAttachmentType { struct ComposerToolbarViewState: BindableState { var composerMode: ComposerMode = .default var composerEmpty = true + /// Could be false if sending is disabled in the room + var canSend = true var suggestions: [SuggestionItem] = [] var audioPlayerState: AudioPlayerState var audioRecorderState: AudioRecorderState @@ -97,6 +99,10 @@ struct ComposerToolbarViewState: BindableState { } var sendButtonDisabled: Bool { + if !canSend { + return true + } + if case .previewVoiceMessage = composerMode { return false } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index 18c4eb2a54..7ec6218ede 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -18,8 +18,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private var initialText: String? private let wysiwygViewModel: WysiwygComposerViewModel private let completionSuggestionService: CompletionSuggestionServiceProtocol + private let roomProxy: JoinedRoomProxyProtocol private let analyticsService: AnalyticsService private let draftService: ComposerDraftServiceProtocol + private var identityPinningViolations = [String: RoomMemberProxyProtocol]() private let mentionBuilder: MentionBuilderProtocol private let attributedStringBuilder: AttributedStringBuilderProtocol @@ -43,6 +45,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private var replyLoadingTask: Task? init(initialText: String? = nil, + roomProxy: JoinedRoomProxyProtocol, wysiwygViewModel: WysiwygComposerViewModel, completionSuggestionService: CompletionSuggestionServiceProtocol, mediaProvider: MediaProviderProtocol, @@ -53,6 +56,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool self.wysiwygViewModel = wysiwygViewModel self.completionSuggestionService = completionSuggestionService self.analyticsService = analyticsService + self.roomProxy = roomProxy draftService = composerDraftService mentionBuilder = MentionBuilder() @@ -120,6 +124,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper) focusComposerIfHardwareKeyboardConnected() + + let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main) + + Task { [weak self] in + for await changes in identityStatusChangesPublisher.values { + guard !Task.isCancelled else { + return + } + + await self?.processIdentityStatusChanges(changes) + } + } + .store(in: &cancellables) } // MARK: - Public @@ -477,6 +494,25 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } } } + + private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async { + for change in changes { + switch change.changedTo { + case .verificationViolation: + guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else { + MXLog.error("Failed retrieving room member for identity status change: \(change)") + continue + } + + identityPinningViolations[change.userId] = member + default: + // clear + identityPinningViolations[change.userId] = nil + } + } + + state.canSend = identityPinningViolations.isEmpty + } private func set(mode: ComposerMode) { if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID { diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift index 6810197b09..0ccf76e06f 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift @@ -44,6 +44,7 @@ struct ComposerToolbar: View { .offset(y: -frame.height) } } + .disabled(!context.viewState.canSend) .alert(item: $context.alertInfo) } @@ -297,7 +298,7 @@ struct ComposerToolbar: View { struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { static let wysiwygViewModel = WysiwygComposerViewModel() - static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + static let composerViewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, @@ -331,6 +332,11 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { ComposerToolbar.replyLoadingPreviewMock(isLoading: false) } .previewDisplayName("Reply") + + VStack(spacing: 8) { + ComposerToolbar.disabledPreviewMock() + } + .previewDisplayName("Disabled") } } @@ -338,7 +344,7 @@ extension ComposerToolbar { static func mock(focused: Bool = true) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() var composerViewModel: ComposerToolbarViewModel { - let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, @@ -355,7 +361,7 @@ extension ComposerToolbar { static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() var composerViewModel: ComposerToolbarViewModel { - let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, @@ -372,7 +378,7 @@ extension ComposerToolbar { static func voiceMessageRecordingMock() -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() var composerViewModel: ComposerToolbarViewModel { - let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, @@ -390,7 +396,7 @@ extension ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() let waveformData: [Float] = Array(repeating: 1.0, count: 1000) var composerViewModel: ComposerToolbarViewModel { - let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, @@ -411,7 +417,7 @@ extension ComposerToolbar { static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() var composerViewModel: ComposerToolbarViewModel { - let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, @@ -430,4 +436,21 @@ extension ComposerToolbar { wysiwygViewModel: wysiwygViewModel, keyCommands: []) } + + static func disabledPreviewMock() -> ComposerToolbar { + let wysiwygViewModel = WysiwygComposerViewModel() + var composerViewModel: ComposerToolbarViewModel { + let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MediaProviderMock(configuration: .init()), + mentionDisplayHelper: ComposerMentionDisplayHelper.mock, + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) + model.state.canSend = false + return model + } + return ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommands: []) + } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift index bd98866781..a27a9393ce 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift @@ -12,6 +12,8 @@ import WysiwygComposer struct RoomAttachmentPicker: View { @ObservedObject var context: ComposerToolbarViewModel.Context + @Environment(\.isEnabled) private var isEnabled + var body: some View { // Use a menu instead of the popover/sheet shown in Figma because overriding the colour scheme // results in a rendering bug on 17.1: https://github.com/element-hq/element-x-ios/issues/2157 @@ -20,6 +22,9 @@ struct RoomAttachmentPicker: View { } label: { CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .compound.headingLG) .scaledPadding(7, relativeTo: .compound.headingLG) + .foregroundColor( + isEnabled ? .compound.iconPrimary : .compound.iconDisabled + ) } .buttonStyle(RoomAttachmentPickerButtonStyle()) .accessibilityLabel(L10n.actionAddToTimeline) @@ -81,7 +86,8 @@ private struct RoomAttachmentPickerButtonStyle: ButtonStyle { } struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview { - static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel(), + static let viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(), + wysiwygViewModel: WysiwygComposerViewModel(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/VoiceMessageRecordingButton.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/VoiceMessageRecordingButton.swift index 448f34bd29..283872d497 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/VoiceMessageRecordingButton.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/VoiceMessageRecordingButton.swift @@ -14,6 +14,8 @@ enum VoiceMessageRecordingButtonMode { } struct VoiceMessageRecordingButton: View { + @Environment(\.isEnabled) private var isEnabled + let mode: VoiceMessageRecordingButtonMode var startRecording: (() -> Void)? var stopRecording: (() -> Void)? @@ -33,7 +35,9 @@ struct VoiceMessageRecordingButton: View { switch mode { case .idle: CompoundIcon(\.micOn, size: .medium, relativeTo: .compound.headingLG) - .foregroundColor(.compound.iconSecondary) + .foregroundColor( + isEnabled ? .compound.iconSecondary : .compound.iconDisabled + ) .scaledPadding(10, relativeTo: .compound.headingLG) case .recording: CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .compound.headingLG) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index a3421b7df7..992a535103 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -90,7 +90,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { maxCompressedHeight: ComposerConstant.maxHeight, maxExpandedHeight: ComposerConstant.maxHeight, parserStyle: .elementX) - let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText, + let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText, roomProxy: parameters.roomProxy, wysiwygViewModel: wysiwygViewModel, completionSuggestionService: parameters.completionSuggestionService, mediaProvider: parameters.mediaProvider, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 34bb4de3f2..4a5bc7c543 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -71,10 +71,12 @@ struct RoomScreenViewStateBindings { } enum RoomScreenFooterViewAction { case resolvePinViolation(userID: String) + case resolveVerificationViolation(userID: String) } enum RoomScreenFooterViewDetails { case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL) + case verificationViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL) } enum PinnedEventsBannerState: Equatable { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index f877ec5a5e..eb0bea11ce 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -25,6 +25,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let pinnedEventStringBuilder: RoomEventStringBuilder private var identityPinningViolations = [String: RoomMemberProxyProtocol]() + private var identityVerificationViolations = [String: RoomMemberProxyProtocol]() private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { @@ -102,6 +103,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch action { case .resolvePinViolation(let userID): Task { await resolveIdentityPinningViolation(userID) } + case .resolveVerificationViolation(let userID): + Task { await resolveIdentityVerificationViolation(userID) } } case .acceptKnock(let eventID): Task { await acceptKnock(eventID: eventID) } @@ -209,21 +212,31 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async { for change in changes { switch change.changedTo { - case .pinned: - identityPinningViolations[change.userId] = nil case .pinViolation: guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else { MXLog.error("Failed retrieving room member for identity status change: \(change)") continue } - + identityPinningViolations[change.userId] = member + case .verificationViolation: + guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else { + MXLog.error("Failed retrieving room member for identity status change: \(change)") + continue + } + + identityVerificationViolations[change.userId] = member default: - break + // clear all + identityVerificationViolations[change.userId] = nil + identityPinningViolations[change.userId] = nil } } - if let member = identityPinningViolations.values.first { + if let member = identityVerificationViolations.values.first { + state.footerDetails = .verificationViolation(member: member, + learnMoreURL: appSettings.identityPinningViolationDetailsURL) + } else if let member = identityPinningViolations.values.first { state.footerDetails = .pinViolation(member: member, learnMoreURL: appSettings.identityPinningViolationDetailsURL) } else { @@ -243,6 +256,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } + private func resolveIdentityVerificationViolation(_ userID: String) async { + defer { + hideLoadingIndicator() + } + + showLoadingIndicator() + + if case .failure = await clientProxy.withdrawUserIdentityVerification(userID) { + userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError) + } + } + private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) { var pinnedEventContents = OrderedDictionary() diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift index 8fc83baf02..85ca6e67d8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift @@ -15,17 +15,25 @@ struct RoomScreenFooterView: View { var body: some View { if let details { ZStack(alignment: .top) { - VStack(spacing: 0) { - Color.compound.borderInfoSubtle - .frame(height: 1) - LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault], - startPoint: .top, - endPoint: .bottom) - } - switch details { case .pinViolation(let member, let learnMoreURL): + VStack(spacing: 0) { + Color.compound.borderInfoSubtle + .frame(height: 1) + LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault], + startPoint: .top, + endPoint: .bottom) + } pinViolation(member: member, learnMoreURL: learnMoreURL) + case .verificationViolation(member: let member, learnMoreURL: let learnMoreURL): + VStack(spacing: 0) { + Color.compound.borderCriticalSubtle + .frame(height: 1) + LinearGradient(colors: [.compound.bgCriticalSubtle, .compound.bgCanvasDefault], + startPoint: .top, + endPoint: .bottom) + } + verificationViolation(member: member, learnMoreURL: learnMoreURL) } } .padding(.top, 8) @@ -60,6 +68,33 @@ struct RoomScreenFooterView: View { .padding(.bottom, 8) } + private func verificationViolation(member: RoomMemberProxyProtocol, + learnMoreURL: URL) -> some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + LoadableAvatarImage(url: member.avatarURL, + name: member.disambiguatedDisplayName, + contentID: member.userID, + avatarSize: .user(on: .timeline), + mediaProvider: mediaProvider) + + Text(verificationViolationDescriptionWithLearnMoreLink(displayName: member.displayName, + userID: member.userID, + url: learnMoreURL)) + .font(.compound.bodyMD) + .foregroundColor(.compound.textCriticalPrimary) + } + + Button(L10n.cryptoIdentityChangeWithdrawVerificationAction) { + callback(.resolveVerificationViolation(userID: member.userID)) + } + .buttonStyle(.compound(.primary, size: .medium)) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + private func pinViolationDescriptionWithLearnMoreLink(displayName: String?, userID: String, url: URL) -> AttributedString { let userIDPlaceholder = "{mxid}" let linkPlaceholder = "{link}" @@ -77,6 +112,23 @@ struct RoomScreenFooterView: View { return description } + private func verificationViolationDescriptionWithLearnMoreLink(displayName: String?, userID: String, url: URL) -> AttributedString { + let userIDPlaceholder = "{mxid}" + let linkPlaceholder = "{link}" + let displayName = displayName ?? fallbackDisplayName(userID) + var description = AttributedString(L10n.cryptoIdentityChangeVerificationViolationNew(displayName, userIDPlaceholder, linkPlaceholder)) + + var userIDString = AttributedString(L10n.cryptoIdentityChangePinViolationNewUserId(userID)) + userIDString.bold() + description.replace(userIDPlaceholder, with: userIDString) + + var linkString = AttributedString(L10n.actionLearnMore) + linkString.link = url + linkString.bold() + description.replace(linkPlaceholder, with: linkString) + return description + } + private func fallbackDisplayName(_ userID: String) -> String { guard let localpart = userID.components(separatedBy: ":").first else { return userID } return String(localpart.trimmingPrefix("@")) @@ -89,10 +141,15 @@ struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview { static let noNameDetails: RoomScreenFooterViewDetails = .pinViolation(member: RoomMemberProxyMock.mockNoName, learnMoreURL: "https://element.io/") + static let verificationViolationDetails: RoomScreenFooterViewDetails = .verificationViolation(member: RoomMemberProxyMock.mockBob, + learnMoreURL: "https://element.io/") + static var previews: some View { RoomScreenFooterView(details: bobDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in } .previewDisplayName("With displayname") RoomScreenFooterView(details: noNameDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in } .previewDisplayName("Without displayname") + RoomScreenFooterView(details: verificationViolationDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in } + .previewDisplayName("Verification Violation") } } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index d5451b5225..42a425f4af 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -1005,6 +1005,22 @@ class ClientProxy: ClientProxyProtocol { } } + func withdrawUserIdentityVerification(_ userID: String) async -> Result { + MXLog.info("Withdrawing current identity verification for user: \(userID)") + + do { + guard let userIdentity = try await client.encryption().userIdentity(userId: userID) else { + MXLog.error("Failed retrieving identity for user: \(userID)") + return .failure(.failedRetrievingUserIdentity) + } + + return try await .success(userIdentity.withdrawVerification()) + } catch { + MXLog.error("Failed withdrawing current identity verification for user: \(error)") + return .failure(.sdkError(error)) + } + } + func resetIdentity() async -> Result { do { return try await .success(client.encryption().resetIdentity()) diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index d4e5f0ff52..088d0df17e 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -204,6 +204,7 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func curve25519Base64() async -> String? func pinUserIdentity(_ userID: String) async -> Result + func withdrawUserIdentityVerification(_ userID: String) async -> Result func resetIdentity() async -> Result func userIdentity(for userID: String) async -> Result