Give the removal confirmation the dialog the team asked for

The dialog now reads "Confirm removal" over "Are you sure you wish to remove
this message?", with the reason field marked as optional beneath it, rather
than a bare title and a labelled field. TextFieldDialog gained a supporting
text so the field can carry that note.

The pending removal moves from a nullable timeline item to redactEventAction,
an AsyncAction whose Confirming state carries the event id, so it is modelled
the same way as every other action the user has to confirm.
This commit is contained in:
hayaksi1
2026-08-27 22:21:09 +03:00
parent 1f4d8b1733
commit 74a91a7348
8 changed files with 50 additions and 33 deletions
@@ -57,6 +57,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -83,6 +84,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -240,7 +242,7 @@ class MessagesPresenter(
onPauseOrDispose {}
}
val eventToRedact = remember { mutableStateOf<TimelineItem.Event?>(null) }
val redactEventAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: MessagesEvent) {
when (event) {
@@ -252,20 +254,20 @@ class MessagesPresenter(
enableTextFormatting = composerState.showTextFormatting,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
eventToRedact = eventToRedact,
redactEventAction = redactEventAction,
)
}
is MessagesEvent.ConfirmRedact -> {
val target = eventToRedact.value
eventToRedact.value = null
if (target != null) {
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
redactEventAction.value = AsyncAction.Uninitialized
if (confirming != null) {
localCoroutineScope.launch {
redact(target, event.reason?.takeIf { it.isNotBlank() })
redact(confirming.eventId.toEventOrTransactionId(), event.reason?.takeIf { it.isNotBlank() })
}
}
}
MessagesEvent.CancelRedact -> {
eventToRedact.value = null
redactEventAction.value = AsyncAction.Uninitialized
}
is MessagesEvent.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
@@ -345,7 +347,7 @@ class MessagesPresenter(
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
eventToRedact = eventToRedact.value,
redactEventAction = redactEventAction.value,
eventSink = ::handleEvent,
)
}
@@ -384,13 +386,13 @@ class MessagesPresenter(
timelineProtectionState: TimelineProtectionState,
enableTextFormatting: Boolean,
timelineState: TimelineState,
eventToRedact: MutableState<TimelineItem.Event?>,
redactEventAction: MutableState<AsyncAction<Unit>>,
) = launch {
when (action) {
TimelineItemAction.CopyText -> handleCopyContents(targetEvent)
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent, eventToRedact)
TimelineItemAction.Redact -> handleActionRedact(targetEvent, redactEventAction)
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
@@ -512,18 +514,19 @@ class MessagesPresenter(
)
}
private suspend fun handleActionRedact(event: TimelineItem.Event, eventToRedact: MutableState<TimelineItem.Event?>) {
if (event.eventId == null) {
private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState<AsyncAction<Unit>>) {
val eventId = event.eventId
if (eventId == null) {
// The message was never sent, so there is nobody to give a reason to.
redact(event, reason = null)
redact(event.eventOrTransactionId, reason = null)
} else {
eventToRedact.value = event
redactEventAction.value = MessagesState.ConfirmingRedaction(eventId)
}
}
private suspend fun redact(event: TimelineItem.Event, reason: String?) {
private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) {
timelineController.invokeOnCurrentTimeline {
redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = reason)
redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason)
.onFailure { Timber.e(it) }
}
}
@@ -18,13 +18,14 @@ import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
@@ -62,8 +63,7 @@ data class MessagesState(
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
/** The event the user asked to remove, waiting for them to confirm and optionally give a reason. */
val eventToRedact: TimelineItem.Event?,
val redactEventAction: AsyncAction<Unit>,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
@@ -72,6 +72,11 @@ data class MessagesState(
val hasThreads: Boolean,
val hasUnreadThreads: Boolean,
)
/** The user asked to remove [eventId] and has to confirm, optionally giving a reason. */
data class ConfirmingRedaction(
val eventId: EventId,
) : AsyncAction.Confirming
}
/** Type of "shared history" icon to show in the top bar. */
@@ -24,7 +24,6 @@ import io.element.android.features.messages.impl.messagecomposer.aMessageCompose
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
@@ -42,10 +41,12 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -93,7 +94,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider<MessagesState> {
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
),
aMessagesState(eventToRedact = aTimelineItemEvent()),
aMessagesState(redactEventAction = MessagesState.ConfirmingRedaction(EventId("\$anEventId"))),
)
}
@@ -133,7 +134,7 @@ fun aMessagesState(
isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {},
eventToRedact: TimelineItem.Event? = null,
redactEventAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = MessagesState(
roomId = RoomId("!id:domain"),
roomName = roomName,
@@ -164,7 +165,7 @@ fun aMessagesState(
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
eventToRedact = eventToRedact,
redactEventAction = redactEventAction,
eventSink = eventSink,
)
@@ -457,11 +457,12 @@ fun MessagesView(
},
)
if (state.eventToRedact != null) {
if (state.redactEventAction is MessagesState.ConfirmingRedaction) {
TextFieldDialog(
title = stringResource(CommonStrings.action_remove_message),
label = stringResource(CommonStrings.common_reason),
placeholder = null,
title = stringResource(CommonStrings.screen_room_confirm_removal_title),
content = stringResource(CommonStrings.screen_room_confirm_removal_message),
placeholder = stringResource(CommonStrings.common_reason),
supportingText = stringResource(CommonStrings.common_optional),
value = null,
submitText = stringResource(CommonStrings.action_remove),
destructiveSubmit = true,
@@ -45,6 +45,7 @@ import io.element.android.features.messages.test.timeline.voicemessages.composer
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -561,11 +562,11 @@ class MessagesPresenterTest {
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent))
advanceUntilIdle()
val confirmingState = expectMostRecentItem()
assertThat(confirmingState.eventToRedact).isEqualTo(messageEvent)
assertThat(confirmingState.redactEventAction).isEqualTo(MessagesState.ConfirmingRedaction(messageEvent.eventId!!))
assert(redactEventLambda).isNeverCalled()
confirmingState.eventSink(MessagesEvent.ConfirmRedact(A_REASON))
advanceUntilIdle()
assertThat(expectMostRecentItem().eventToRedact).isNull()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(messageEvent.eventOrTransactionId), value(A_REASON))
@@ -589,7 +590,7 @@ class MessagesPresenterTest {
advanceUntilIdle()
expectMostRecentItem().eventSink(MessagesEvent.ConfirmRedact(" "))
advanceUntilIdle()
assertThat(expectMostRecentItem().eventToRedact).isNull()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(messageEvent.eventOrTransactionId), value(null))
@@ -612,7 +613,7 @@ class MessagesPresenterTest {
advanceUntilIdle()
expectMostRecentItem().eventSink(MessagesEvent.CancelRedact)
advanceUntilIdle()
assertThat(expectMostRecentItem().eventToRedact).isNull()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda).isNeverCalled()
}
}
@@ -632,7 +633,7 @@ class MessagesPresenterTest {
val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho))
advanceUntilIdle()
assertThat(expectMostRecentItem().eventToRedact).isNull()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(localEcho.eventOrTransactionId), value(null))
@@ -47,6 +47,7 @@ fun TextFieldDialog(
maxLines: Int = minLines,
content: String? = null,
label: String? = null,
supportingText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
submitText: String = stringResource(CommonStrings.action_ok),
destructiveSubmit: Boolean = false,
@@ -84,6 +85,7 @@ fun TextFieldDialog(
TextFieldListItem(
placeholder = placeholder.orEmpty(),
label = label,
supportingText = supportingText,
text = textFieldContents,
onTextChange = {
error = if (!validation(it.text)) onValidationErrorMessage else null
@@ -57,6 +57,7 @@ fun TextFieldListItem(
minLines: Int = 1,
maxLines: Int = minLines,
label: String? = null,
supportingText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
@@ -66,7 +67,7 @@ fun TextFieldListItem(
placeholder = placeholder,
label = label,
validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = error,
supportingText = error ?: supportingText,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
maxLines = maxLines,
@@ -10,4 +10,7 @@
<string name="a11y_state_collapsed">"Collapsed"</string>
<string name="a11y_active">Active</string>
<string name="a11y_inactive">Inactive</string>
<string name="common_optional">"(Optional)"</string>
<string name="screen_room_confirm_removal_title">"Confirm removal"</string>
<string name="screen_room_confirm_removal_message">"Are you sure you wish to remove this message?"</string>
</resources>