diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 4703fd47f..7e62e1ddf 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -491,6 +491,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "ReadReceiptsSummaryView_Previews") } + func testRedactConfirmationView() async throws { + try await performAccessibilityAudit(named: "RedactConfirmationView_Previews") + } + func testRedactedRoomTimelineView() async throws { try await performAccessibilityAudit(named: "RedactedRoomTimelineView_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 5728b8c39..6758c55b9 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1079,6 +1079,7 @@ A91D125414C3D9ABBABCF2F1 /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 6690850AA47ECED7E1CAB345 /* KZFileWatchers */; }; A93661C962B12942C08864B6 /* Emojibase in Frameworks */ = {isa = PBXBuildFile; productRef = C05729B1684C331F5FFE9232 /* Emojibase */; }; A950C95855C474F75B30CA7B /* PollFormScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDAB580109C09A6AA97AF7E /* PollFormScreenTests.swift */; }; + A96013671CAEFDE868C7E0D8 /* RedactConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC720B8D99D1490CCDBED6CE /* RedactConfirmationView.swift */; }; A969147E0EEE0E27EE226570 /* MediaUploadPreviewScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */; }; A975D60EA49F6AF73308809F /* RoomMembersListScreenMemberCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC03209FDE8CE0810617BFFF /* RoomMembersListScreenMemberCell.swift */; }; A98CB75060E4C9BB5D41531B /* EncryptionSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2734CCFE59E5EDC216602910 /* EncryptionSDKMock.swift */; }; @@ -3126,6 +3127,7 @@ EBEB8D9F4940E161B18FE4BC /* UITestsNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsNotificationCenter.swift; sourceTree = ""; }; EC446234C8E5031F36F1BBE2 /* ContentScannerServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerServiceMock.swift; sourceTree = ""; }; EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsViewModelTests.swift; sourceTree = ""; }; + EC720B8D99D1490CCDBED6CE /* RedactConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactConfirmationView.swift; sourceTree = ""; }; ECB836DD8BE31931F51B8AC9 /* EncryptionSettingsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionSettingsFlowCoordinator.swift; sourceTree = ""; }; ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenViewModelTests.swift; sourceTree = ""; }; ECD5FCBA169B6A82F501CA1B /* AnalyticsSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -7380,6 +7382,7 @@ isa = PBXGroup; children = ( ECEC9A622AA2F8DBE928AC78 /* FloatingDateBadge.swift */, + EC720B8D99D1490CCDBED6CE /* RedactConfirmationView.swift */, BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */, D53FCCE44F96E0BC411A6CF0 /* TimelineSenderAvatarView.swift */, 93C713D124FE915ABF47A6B7 /* TimelineView.swift */, @@ -9133,6 +9136,7 @@ 1653275750CE11F5CE94DDFD /* ReadReceiptsSummaryView.swift in Sources */, C76892321558E75101E68ED6 /* ReadableFrameModifier.swift in Sources */, 75072C529C9C363F531721F1 /* RecoveryKeyScreenHook.swift in Sources */, + A96013671CAEFDE868C7E0D8 /* RedactConfirmationView.swift in Sources */, AF19D65A9C60C6B2646F3210 /* RedactedRoomTimelineItem.swift in Sources */, 8CFDA5F1562479CB3A34D277 /* RedactedRoomTimelineView.swift in Sources */, 3A2640C52505BAB3D983A92A /* RemotePreference.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 697bda20f..43042c515 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -1289,6 +1289,10 @@ "screen_room_change_role_section_users" = "Members"; "screen_room_change_role_unsaved_changes_description" = "You have unsaved changes."; "screen_room_change_role_unsaved_changes_title" = "Save changes?"; +"screen_room_confirm_removal_message" = "Are you sure you wish to remove this message?"; +"screen_room_confirm_removal_reason_label" = "Reason (optional)"; +"screen_room_confirm_removal_reason_placeholder" = "Enter…"; +"screen_room_confirm_removal_title" = "Confirm removal"; "screen_room_details_add_topic_title" = "Add topic"; "screen_room_details_badge_encrypted" = "Encrypted"; "screen_room_details_badge_not_encrypted" = "Not encrypted"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index ac9fb2829..d7abb6956 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -1289,6 +1289,10 @@ "screen_room_change_role_section_users" = "Members"; "screen_room_change_role_unsaved_changes_description" = "You have unsaved changes."; "screen_room_change_role_unsaved_changes_title" = "Save changes?"; +"screen_room_confirm_removal_message" = "Are you sure you wish to remove this message?"; +"screen_room_confirm_removal_reason_label" = "Reason (optional)"; +"screen_room_confirm_removal_reason_placeholder" = "Enter…"; +"screen_room_confirm_removal_title" = "Confirm removal"; "screen_room_details_add_topic_title" = "Add topic"; "screen_room_details_badge_encrypted" = "Encrypted"; "screen_room_details_badge_not_encrypted" = "Not encrypted"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 9e1fe9cc4..3f920bd04 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -2992,6 +2992,14 @@ internal nonisolated enum L10n { internal static var screenRoomChangeRoleUnsavedChangesDescription: String { return L10n.tr("Localizable", "screen_room_change_role_unsaved_changes_description") } /// Save changes? internal static var screenRoomChangeRoleUnsavedChangesTitle: String { return L10n.tr("Localizable", "screen_room_change_role_unsaved_changes_title") } + /// Are you sure you wish to remove this message? + internal static var screenRoomConfirmRemovalMessage: String { return L10n.tr("Localizable", "screen_room_confirm_removal_message") } + /// Reason (optional) + internal static var screenRoomConfirmRemovalReasonLabel: String { return L10n.tr("Localizable", "screen_room_confirm_removal_reason_label") } + /// Enter… + internal static var screenRoomConfirmRemovalReasonPlaceholder: String { return L10n.tr("Localizable", "screen_room_confirm_removal_reason_placeholder") } + /// Confirm removal + internal static var screenRoomConfirmRemovalTitle: String { return L10n.tr("Localizable", "screen_room_confirm_removal_title") } /// Add topic internal static var screenRoomDetailsAddTopicTitle: String { return L10n.tr("Localizable", "screen_room_details_add_topic_title") } /// Encrypted diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index a738736b2..2aeaff27e 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -11988,34 +11988,34 @@ nonisolated class TimelineControllerMock: TimelineControllerProtocol, @unchecked } //MARK: - redact - private let redactCallsCountLock = NSLock() - private nonisolated(unsafe) var redactUnderlyingCallsCount = 0 - var redactCallsCount: Int { - get { redactCallsCountLock.withLock { redactUnderlyingCallsCount } } - set { redactCallsCountLock.withLock { redactUnderlyingCallsCount = newValue } } + private let redactReasonCallsCountLock = NSLock() + private nonisolated(unsafe) var redactReasonUnderlyingCallsCount = 0 + var redactReasonCallsCount: Int { + get { redactReasonCallsCountLock.withLock { redactReasonUnderlyingCallsCount } } + set { redactReasonCallsCountLock.withLock { redactReasonUnderlyingCallsCount = newValue } } } - var redactCalled: Bool { - return redactCallsCount > 0 + var redactReasonCalled: Bool { + return redactReasonCallsCount > 0 } - private let redactReceivedEventOrTransactionIDLock = NSLock() - private nonisolated(unsafe) var redactUnderlyingReceivedEventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID? - var redactReceivedEventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID? { - get { redactReceivedEventOrTransactionIDLock.withLock { redactUnderlyingReceivedEventOrTransactionID } } - set { redactReceivedEventOrTransactionIDLock.withLock { redactUnderlyingReceivedEventOrTransactionID = newValue } } + private let redactReasonReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var redactReasonUnderlyingReceivedArguments: (eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?)? + var redactReasonReceivedArguments: (eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?)? { + get { redactReasonReceivedArgumentsLock.withLock { redactReasonUnderlyingReceivedArguments } } + set { redactReasonReceivedArgumentsLock.withLock { redactReasonUnderlyingReceivedArguments = newValue } } } - private let redactReceivedInvocationsLock = NSLock() - private nonisolated(unsafe) var redactUnderlyingReceivedInvocations: [TimelineItemIdentifier.EventOrTransactionID] = [] - var redactReceivedInvocations: [TimelineItemIdentifier.EventOrTransactionID] { - get { redactReceivedInvocationsLock.withLock { redactUnderlyingReceivedInvocations } } - set { redactReceivedInvocationsLock.withLock { redactUnderlyingReceivedInvocations = newValue } } + private let redactReasonReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var redactReasonUnderlyingReceivedInvocations: [(eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?)] = [] + var redactReasonReceivedInvocations: [(eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?)] { + get { redactReasonReceivedInvocationsLock.withLock { redactReasonUnderlyingReceivedInvocations } } + set { redactReasonReceivedInvocationsLock.withLock { redactReasonUnderlyingReceivedInvocations = newValue } } } - nonisolated(unsafe) var redactClosure: ((TimelineItemIdentifier.EventOrTransactionID) async -> Void)? + nonisolated(unsafe) var redactReasonClosure: ((TimelineItemIdentifier.EventOrTransactionID, String?) async -> Void)? - @concurrent func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async { - redactCallsCountLock.withLock { redactUnderlyingCallsCount += 1 } - redactReceivedEventOrTransactionID = eventOrTransactionID - redactReceivedInvocationsLock.withLock { redactUnderlyingReceivedInvocations.append(eventOrTransactionID) } - await redactClosure?(eventOrTransactionID) + @concurrent func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?) async { + redactReasonCallsCountLock.withLock { redactReasonUnderlyingCallsCount += 1 } + redactReasonReceivedArguments = (eventOrTransactionID: eventOrTransactionID, reason: reason) + redactReasonReceivedInvocationsLock.withLock { redactReasonUnderlyingReceivedInvocations.append((eventOrTransactionID: eventOrTransactionID, reason: reason)) } + await redactReasonClosure?(eventOrTransactionID, reason) } //MARK: - pin diff --git a/ElementX/Sources/Mocks/TimelineControllerMock.swift b/ElementX/Sources/Mocks/TimelineControllerMock.swift index d4b9bf188..e68a850b2 100644 --- a/ElementX/Sources/Mocks/TimelineControllerMock.swift +++ b/ElementX/Sources/Mocks/TimelineControllerMock.swift @@ -74,9 +74,9 @@ struct TimelineControllerMockConfiguration { _ = await timelineProxy.toggleReaction(reaction, to: eventOrTransactionID) } - redactClosure = { [timelineProxy] eventOrTransactionID in + redactReasonClosure = { [timelineProxy] eventOrTransactionID, reason in guard let timelineProxy else { return } - _ = await timelineProxy.redact(eventOrTransactionID, reason: nil) + _ = await timelineProxy.redact(eventOrTransactionID, reason: reason) } editMessageHtmlIntentionalMentionsClosure = { [weak self] _, message, _, _ in diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 545ae70ea..1c190ab5b 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -126,6 +126,7 @@ enum TestablePreviewsDictionary { "ReadMarkerRoomTimelineView_Previews" : ReadMarkerRoomTimelineView_Previews.self, "ReadReceiptCell_Previews" : ReadReceiptCell_Previews.self, "ReadReceiptsSummaryView_Previews" : ReadReceiptsSummaryView_Previews.self, + "RedactConfirmationView_Previews" : RedactConfirmationView_Previews.self, "RedactedRoomTimelineView_Previews" : RedactedRoomTimelineView_Previews.self, "ReportContentScreen_Previews" : ReportContentScreen_Previews.self, "ReportRoomScreen_Previews" : ReportRoomScreen_Previews.self, diff --git a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift index b0c1b6534..acd569abe 100644 --- a/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift +++ b/ElementX/Sources/Screens/CreatePollScreen/PollFormScreenViewModel.swift @@ -52,11 +52,19 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo } } case .delete: + // A blank reason is no reason at all, so don't send one. + var reason: String? + let binding: Binding = .init(get: { reason ?? "" }, + set: { reason = $0.isBlank ? nil : $0 }) state.bindings.alertInfo = .init(id: .init(), title: L10n.screenEditPollDeleteConfirmationTitle, message: L10n.screenEditPollDeleteConfirmation, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOk) { Task { await self.deletePoll() } }) + secondaryButton: .init(title: L10n.actionOk) { Task { await self.deletePoll(reason: reason) } }, + textFields: [.init(placeholder: L10n.screenRoomConfirmRemovalReasonLabel, + text: binding, + autoCapitalization: .sentences, + autoCorrectionDisabled: false)]) case .cancel: if state.formContentHasChanged { state.bindings.alertInfo = .init(id: .init(), @@ -116,7 +124,7 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo } } - private func deletePoll() async { + private func deletePoll(reason: String?) async { // There aren't any local echoes for redactions, so dismiss the screen early // until we have them: https://github.com/matrix-org/matrix-rust-sdk/issues/4162 actionsSubject.send(.close) @@ -126,6 +134,6 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo return } - await timelineController.redact(.eventID(pollStartID)) + await timelineController.redact(.eventID(pollStartID), reason: reason) } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 02852d146..402f57a8b 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -91,6 +91,6 @@ enum TimelineMediaPreviewViewAction { case updateCurrentItem(TimelineMediaPreviewItem) case showItemDetails(TimelineMediaPreviewItem.Media) case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem.Media) - case redactConfirmation(item: TimelineMediaPreviewItem.Media) + case redactConfirmation(item: TimelineMediaPreviewItem.Media, reason: String) case timelineEndReached } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 0802d2920..9aac6837d 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -128,8 +128,8 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { default: MXLog.error("Received unexpected action: \(action)") } - case .redactConfirmation(let item): - redactItem(item) + case .redactConfirmation(let item, let reason): + redactItem(item, reason: reason) case .timelineEndReached: showTimelineEndIndicator() } @@ -267,8 +267,8 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } } - private func redactItem(_ item: TimelineMediaPreviewItem.Media) { - timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.timelineItem.id, action: .redact(isMedia: true))) + private func redactItem(_ item: TimelineMediaPreviewItem.Media, reason: String) { + timelineViewModel.context.send(viewAction: .redactConfirmed(itemID: item.timelineItem.id, reason: reason)) state.bindings.redactConfirmationItem = nil state.previewControllerDriver.send(.dismissDetailsSheet) actionsSubject.send(.dismiss) diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index e32761fe2..383de10c8 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -16,6 +16,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View { @ObservedObject var context: TimelineMediaPreviewViewModel.Context var preferredColorScheme: ColorScheme? = .dark + @State private var reason = "" @State private var sheetHeight: CGFloat = .zero private let topPadding: CGFloat = 19 @@ -24,6 +25,10 @@ struct TimelineMediaPreviewRedactConfirmationView: View { VStack(spacing: 0) { header preview + TextField(L10n.screenRoomConfirmRemovalReasonPlaceholder, text: $reason) + .textFieldStyle(.compound(labelText: L10n.screenRoomConfirmRemovalReasonLabel)) + .padding(.horizontal, 24) + .padding(.bottom, 40) buttons } .readHeight($sheetHeight) @@ -95,13 +100,13 @@ struct TimelineMediaPreviewRedactConfirmationView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) - .padding(.bottom, 40) + .padding(.bottom, 24) } private var buttons: some View { VStack(spacing: 16) { Button(L10n.actionRemove, role: .destructive) { - context.send(viewAction: .redactConfirmation(item: item)) + context.send(viewAction: .redactConfirmation(item: item, reason: reason)) } .buttonStyle(.compound(.primary)) diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index da660e98f..17f5dd582 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -19,6 +19,7 @@ enum TimelineInteractionHandlerAction { case displayEditPollForm(eventID: String, poll: Poll) case showActionMenu(TimelineItemActionMenuInfo) + case showRedactConfirmation(itemID: TimelineItemIdentifier) case showDebugInfo(TimelineItemDebugInfo) case displayAudioRecorderPermissionError @@ -114,13 +115,24 @@ class TimelineInteractionHandler { } } - // swiftlint:disable:next cyclomatic_complexity - func handleTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: TimelineItemIdentifier) { + func redact(_ itemID: TimelineItemIdentifier, reason: String?) { // Redacting needs the event alone, so it works even when the item isn't part of this timeline, // such as one held by a media preview that was built from a different one. + guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() } + Task { await timelineController.redact(eventOrTransactionID, reason: reason) } + } + + // swiftlint:disable:next cyclomatic_complexity + func handleTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: TimelineItemIdentifier) { if case .redact = action { - guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() } - Task { await timelineController.redact(eventOrTransactionID) } + // An unsent message is only dropped from the send queue. No redaction event reaches + // the server, so there is nothing to attach a reason to, and asking for one would + // only delay the abort while the message might still go out. + if case .event(_, .eventID) = itemID { + actionsSubject.send(.showRedactConfirmation(itemID: itemID)) + } else { + redact(itemID, reason: nil) + } return } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 805c24ded..5155af79c 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -66,6 +66,8 @@ enum TimelineViewAction { case displayTimelineItemMenu(itemID: TimelineItemIdentifier) case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) + /// The user has confirmed the removal of an item, optionally giving a reason. + case redactConfirmed(itemID: TimelineItemIdentifier, reason: String?) case tappedOnSenderDetails(sender: TimelineItemSender) case displayReactionSummary(itemID: TimelineItemIdentifier, key: String) @@ -189,6 +191,8 @@ struct TimelineViewStateBindings { var manageMemberViewModel: ManageRoomMemberSheetViewModel? + var redactConfirmationInfo: TimelineRedactConfirmationInfo? + var showTranslation = false var textToBeTranslated: String? } @@ -219,6 +223,11 @@ struct ReadReceiptSummaryInfo: Identifiable { let id: TimelineItemIdentifier } +/// The item that the user has asked to remove and must confirm, optionally giving a reason. +struct TimelineRedactConfirmationInfo: Identifiable { + let id: TimelineItemIdentifier +} + enum TimelineAlertInfoType: Hashable { case audioRecodingPermissionError case pollEndConfirmation(String) diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index bb98f255d..933b0be95 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -198,6 +198,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) case .handleTimelineItemMenuAction(let itemID, let action): timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID) + case .redactConfirmed(let itemID, let reason): + state.bindings.redactConfirmationInfo = nil + // A blank reason is no reason at all, so don't send one. + timelineInteractionHandler.redact(itemID, reason: reason?.isBlank == false ? reason : nil) case .tappedOnSenderDetails(let sender): handleTappedOnSenderDetails(sender: sender) case .displayEmojiPicker(let itemID): @@ -533,6 +537,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } else { self.state.bindings.actionMenuInfo = actionMenuInfo } + case .showRedactConfirmation(let itemID): + state.bindings.redactConfirmationInfo = .init(id: itemID) case .showDebugInfo(let debugInfo): state.bindings.debugInfo = debugInfo case .viewInRoomTimeline(let eventID): @@ -1121,7 +1127,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { verticalButtons: sendHandle.map { sendHandle in [.init(title: L10n.actionRetry) { [weak self] in self?.retrySending(sendHandle) }, .init(title: L10n.actionRemoveMessage, role: .destructive) { [weak self] in - self?.timelineInteractionHandler.handleTimelineItemMenuAction(.redact(isMedia: false), itemID: sendHandle.itemID) + self?.timelineInteractionHandler.redact(sendHandle.itemID, reason: nil) }] }) case .encryptionAuthenticity(let message): diff --git a/ElementX/Sources/Screens/Timeline/View/RedactConfirmationView.swift b/ElementX/Sources/Screens/Timeline/View/RedactConfirmationView.swift new file mode 100644 index 000000000..191f00036 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/RedactConfirmationView.swift @@ -0,0 +1,102 @@ +// +// 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 + +/// Asks the user to confirm removing a message, letting them give an optional reason. +struct RedactConfirmationView: View { + @Environment(\.dismiss) private var dismiss + + @State var reason = "" + let confirm: (String) -> Void + + @State private var sheetHeight: CGFloat = .zero + private let topPadding: CGFloat = 44 // For the navigation bar + + var body: some View { + ElementNavigationStack { + ScrollView { + VStack(spacing: 0) { + header + TextField(L10n.screenRoomConfirmRemovalReasonPlaceholder, text: $reason) + .textFieldStyle(.compound(labelText: L10n.screenRoomConfirmRemovalReasonLabel)) + buttons + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + .readHeight($sheetHeight) + } + .scrollBounceBehavior(.basedOnSize) + .toolbar { + ToolbarItem(placement: .primaryAction) { + ToolbarButton(role: .close) { dismiss() } + } + } + } + .presentationDetents([.height(sheetHeight + topPadding)]) + .presentationDragIndicator(.hidden) + .presentationBackground(.compound.bgCanvasDefault) + .interactiveDismissDisabled() + } + + private var header: some View { + VStack(spacing: 8) { + Text(L10n.screenRoomConfirmRemovalTitle) + .font(.compound.headingMDBold) + .foregroundStyle(.compound.textPrimary) + + Text(L10n.screenRoomConfirmRemovalMessage) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textSecondary) + } + .multilineTextAlignment(.center) + .padding(.top, 8) + .padding(.bottom, 24) + } + + private var buttons: some View { + ViewThatFits { + HStack(spacing: 12) { + cancelButton + removeButton + } + + VStack(spacing: 16) { + removeButton + cancelButton + } + } + .padding(.top, 24) + } + + private var cancelButton: some View { + Button(L10n.actionCancel) { + dismiss() + } + .buttonStyle(.compound(.secondary)) + } + + private var removeButton: some View { + Button(L10n.actionRemove, role: .destructive) { + confirm(reason) + } + .buttonStyle(.compound(.primary)) + } +} + +// MARK: - Previews + +struct RedactConfirmationView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + RedactConfirmationView { _ in } + .previewDisplayName("Empty") + + RedactConfirmationView(reason: "Posted in the wrong room.") { _ in } + .previewDisplayName("Reason") + } +} diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index d1b2f04e4..42e207ef2 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -27,6 +27,11 @@ struct TimelineView: View { ManageRoomMemberSheetView(context: $0.context) } .sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) } + .sheet(item: $timelineContext.redactConfirmationInfo) { info in + RedactConfirmationView { reason in + timelineContext.send(viewAction: .redactConfirmed(itemID: info.id, reason: reason)) + } + } .sheet(item: $timelineContext.actionMenuInfo) { info in let actions = TimelineItemMenuActionProvider(timelineItem: info.item, canCurrentUserSendMessage: timelineContext.viewState.canCurrentUserSendMessage, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index 1f9735ad1..6fe267b27 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -211,10 +211,10 @@ class TimelineController: TimelineControllerProtocol { } } - func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async { + func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?) async { MXLog.info("Send redaction in \(roomID)") - switch await activeTimeline.redact(eventOrTransactionID, reason: nil) { + switch await activeTimeline.redact(eventOrTransactionID, reason: reason) { case .success: MXLog.info("Finished redacting message") case .failure(let error): diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift index c90b07fc6..cc91239a2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift @@ -84,7 +84,7 @@ protocol TimelineControllerProtocol: Sendable { func toggleReaction(_ reaction: String, to eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async - func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async + func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?) async func pin(eventID: String) async diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 1a1f09507..ce7822679 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -858,6 +858,13 @@ extension PreviewTests { } } + @Test + func redactConfirmationView() async throws { + for (index, preview) in RedactConfirmationView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func redactedRoomTimelineView() async throws { for (index, preview) in RedactedRoomTimelineView_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPad-en-GB.png new file mode 100644 index 000000000..e0ca877ec --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50a6683267263f4089a043f576ea75f0627d7c4949af5cf46e29360dfcafda88 +size 100477 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPad-pseudo.png new file mode 100644 index 000000000..50eb81bd6 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82ad445fabbb5cf0cececed10043f60de400a86cbf4f20902fe848306a0db0cd +size 110867 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPhone-en-GB.png new file mode 100644 index 000000000..4a025e9a4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c7b29567f50f4b423778ed83c5d9190a9999b8d4b2793cfc516eecd31931066 +size 57292 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPhone-pseudo.png new file mode 100644 index 000000000..f869416ec --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Empty-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bde8f15c99202165f0f0680e21f1da7d145c6fbee2203285d4f5f38df731e1e +size 75116 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPad-en-GB.png new file mode 100644 index 000000000..9199f614f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:489d4019f922398b02a2f84ae101a45ebb11e820e2d4bf73117652f51934c2a4 +size 104940 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPad-pseudo.png new file mode 100644 index 000000000..f114b56f3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:460b8fe2c5d35a6fcdfb9193afbd7f75cee402026ba454a8ce79a1ea97cc4b18 +size 114838 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPhone-en-GB.png new file mode 100644 index 000000000..53838c36a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05bbbee75311862ab8b8c4d383776adb8d287181c53a7398f93eb6864c934ae0 +size 60934 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPhone-pseudo.png new file mode 100644 index 000000000..b5b6e2fd1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/redactConfirmationView.Reason-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d413adc7905310d519c2f86aaf4fe83ead011b50b6b435fd0e0f20e1c79a1798 +size 78553 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-en-GB-0.png index 690a8e68c..9abd03188 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb10e7ac82b9e59b99051396a1ea10020f45cd842a6134a2a6e9e7f76d9bcf88 -size 119421 +oid sha256:e19befe39cf7442ca7cfd8fbbfb22774ace1139eac60494f66433e65c6f25bb1 +size 129619 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-pseudo-0.png index 88cef5a22..036be99d4 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b655ff5bd775488bae7bd395a5f629cb7e357f9b1d2c58f04434c451628a231f -size 128199 +oid sha256:52f7f9295b449644328b93a30b0295b43ae90107071d19ecc9bc213757b0e2c9 +size 140006 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-en-GB-0.png index 1aace69c0..694e6e567 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be41db921cf2e5665331b245103aa82856470dcf04a76b920d3c85c8f2b068ef -size 75025 +oid sha256:669015a9290d7833a91e91df9e93febcd98f888925a2fadc4e9427107b228266 +size 81860 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-pseudo-0.png index da6118e41..0d3f0d251 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaPreviewRedactConfirmationView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ea136695fb5b8ed27b7855fc3de4854f0d362ac69f3a0281f4e750510097e93 -size 88094 +oid sha256:0411b45b1d4d25fa551a1c967abbf48f99b43fe847e4e4c4f795ef665910d7a4 +size 97582 diff --git a/UnitTests/Sources/PollFormScreenViewModelTests.swift b/UnitTests/Sources/PollFormScreenViewModelTests.swift index dadd123ef..bdcab515e 100644 --- a/UnitTests/Sources/PollFormScreenViewModelTests.swift +++ b/UnitTests/Sources/PollFormScreenViewModelTests.swift @@ -198,17 +198,44 @@ struct PollFormScreenViewModelTests { try await deferredFailure.fulfill() #expect(context.alertInfo != nil, "An alert should be shown before deleting the poll.") + #expect(context.alertInfo?.textFields?.count == 1, "The alert should let the user give a reason.") let deferred = deferFulfillment(viewModel.actions) { $0 == .close } await waitForConfirmation(timeout: .seconds(1)) { confirmation in - timelineProxy.redactReasonClosure = { eventID, _ in + timelineProxy.redactReasonClosure = { eventID, reason in defer { confirmation() } #expect(eventID == .eventID("foo")) + #expect(reason == nil, "A blank reason shouldn't be sent.") return .success(()) } + context.alertInfo?.textFields?.first?.text.wrappedValue = " " + context.alertInfo?.secondaryButton?.action?() + } + try await deferred.fulfill() + } + + @Test + mutating func deletePollWithReason() async throws { + setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed)) + + context.send(viewAction: .delete) + #expect(context.alertInfo != nil, "An alert should be shown before deleting the poll.") + + let deferred = deferFulfillment(viewModel.actions) { $0 == .close } + + await waitForConfirmation(timeout: .seconds(1)) { confirmation in + timelineProxy.redactReasonClosure = { eventID, reason in + defer { + confirmation() + } + #expect(eventID == .eventID("foo")) + #expect(reason == "Posted in the wrong room.") + return .success(()) + } + context.alertInfo?.textFields?.first?.text.wrappedValue = "Posted in the wrong room." context.alertInfo?.secondaryButton?.action?() } try await deferred.fulfill() diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 5cf8f9469..779aeef58 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -154,7 +154,7 @@ struct TimelineMediaPreviewViewModelTests { // Given a view model with a loaded item. try await loadingItem() #expect(context.redactConfirmationItem == nil) - #expect(!timelineController.redactCalled) + #expect(!timelineController.redactReasonCalled) guard case let .media(mediaItem) = context.viewState.currentItem else { Issue.record("There should be a current item.") return @@ -177,18 +177,19 @@ struct TimelineMediaPreviewViewModelTests { // Then the confirmation sheet should be presented. #expect(context.redactConfirmationItem == mediaItem) - #expect(!timelineController.redactCalled) + #expect(!timelineController.redactReasonCalled) // When confirming the redaction. let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } // The redaction runs in an unstructured task, so wait for the call rather than asserting after the dismiss. await waitForConfirmation { confirmation in - timelineController.redactClosure = { _ in confirmation() } - context.send(viewAction: .redactConfirmation(item: mediaItem)) + timelineController.redactReasonClosure = { _, _ in confirmation() } + context.send(viewAction: .redactConfirmation(item: mediaItem, reason: "Wrong file")) } - // Then the item should be redacted and the view should be dismissed. + // Then the item should be redacted with the reason and the view should be dismissed. + #expect(timelineController.redactReasonReceivedArguments?.reason == "Wrong file") try await deferred.fulfill() } diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index a1cb59774..5478b2e4e 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -594,6 +594,99 @@ final class TimelineViewModelTests { #expect(viewModel.state.bindings.alertInfo?.verticalButtons == nil) } + // MARK: - Redaction + + @Test + func redactionAsksForConfirmation() async throws { + // Given a timeline containing a message that has been sent. + let item = TextRoomTimelineItem(text: "Hello", sender: "bob") + let timelineController = TimelineControllerMock(.init(timelineItems: [item])) + let viewModel = makeViewModel(timelineController: timelineController) + + // When choosing to remove it. + let deferred = deferFulfillment(viewModel.context.$viewState) { $0.bindings.redactConfirmationInfo?.id == item.id } + viewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact(isMedia: false))) + + // Then a confirmation should be shown without anything being redacted yet. + try await deferred.fulfill() + #expect(!timelineController.redactReasonCalled) + } + + @Test + func redactionSendsTheReason() async throws { + // Given a timeline showing a redaction confirmation. + let item = TextRoomTimelineItem(text: "Hello", sender: "bob") + let timelineController = TimelineControllerMock(.init(timelineItems: [item])) + let viewModel = makeViewModel(timelineController: timelineController) + let deferredConfirmation = deferFulfillment(viewModel.context.$viewState) { $0.bindings.redactConfirmationInfo != nil } + viewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact(isMedia: false))) + try await deferredConfirmation.fulfill() + + // When confirming the removal with a reason. + // The redaction runs in an unstructured task, so wait for the call rather than asserting straight after. + await waitForConfirmation { confirmation in + timelineController.redactReasonClosure = { _, _ in confirmation() } + viewModel.context.send(viewAction: .redactConfirmed(itemID: item.id, reason: "Posted in the wrong room")) + } + + // Then the reason should be sent with the redaction and the confirmation dismissed. + #expect(timelineController.redactReasonReceivedArguments?.reason == "Posted in the wrong room") + #expect(viewModel.context.redactConfirmationInfo == nil) + } + + @Test + func redactionIgnoresABlankReason() async { + // Given a timeline containing a message that has been sent. + let item = TextRoomTimelineItem(text: "Hello", sender: "bob") + let timelineController = TimelineControllerMock(.init(timelineItems: [item])) + let viewModel = makeViewModel(timelineController: timelineController) + + // When confirming the removal without typing a reason. + await waitForConfirmation { confirmation in + timelineController.redactReasonClosure = { _, _ in confirmation() } + viewModel.context.send(viewAction: .redactConfirmed(itemID: item.id, reason: " ")) + } + + // Then no reason should be sent, behaving exactly as it did before the confirmation existed. + #expect(timelineController.redactReasonReceivedArguments?.reason == nil) + } + + @Test + func redactionCanBeCancelled() async throws { + // Given a timeline showing a redaction confirmation. + let item = TextRoomTimelineItem(text: "Hello", sender: "bob") + let timelineController = TimelineControllerMock(.init(timelineItems: [item])) + let viewModel = makeViewModel(timelineController: timelineController) + let deferred = deferFulfillment(viewModel.context.$viewState) { $0.bindings.redactConfirmationInfo != nil } + viewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact(isMedia: false))) + try await deferred.fulfill() + + // When dismissing the sheet without confirming. + viewModel.context.redactConfirmationInfo = nil + + // Then nothing should be redacted. + #expect(!timelineController.redactReasonCalled) + } + + @Test + func redactionOfAnUnsentMessageSkipsTheConfirmation() async { + // Given a message that was never sent, so has nobody to give a reason to. + let timelineController = TimelineControllerMock(.init(timelineItems: [])) + let viewModel = makeViewModel(timelineController: timelineController) + let itemID = TimelineItemIdentifier.event(uniqueID: .init(UUID().uuidString), + eventOrTransactionID: .transactionID(UUID().uuidString)) + + // When choosing to remove it. + await waitForConfirmation { confirmation in + timelineController.redactReasonClosure = { _, _ in confirmation() } + viewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: itemID, action: .redact(isMedia: false))) + } + + // Then it should be redacted immediately, with no reason and no confirmation. + #expect(viewModel.context.redactConfirmationInfo == nil) + #expect(timelineController.redactReasonReceivedArguments?.reason == nil) + } + // MARK: - Helpers private func makeViewModel(roomProxy: JoinedRoomProxyProtocol? = nil,