Redaction reason (#6098)

This commit is contained in:
Mauro
2026-09-07 18:57:05 +02:00
committed by GitHub
parent 350d47c94a
commit 346a1e8ebb
35 changed files with 381 additions and 57 deletions
@@ -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()
@@ -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()
}
@@ -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,