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