From 74a91a73487fd0ea4c0363b7a588d73901e0e166 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:21:09 +0300 Subject: [PATCH] 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. --- .../messages/impl/MessagesPresenter.kt | 35 ++++++++++--------- .../features/messages/impl/MessagesState.kt | 11 ++++-- .../impl/MessagesStatePreviewParam.kt | 9 ++--- .../features/messages/impl/MessagesView.kt | 9 ++--- .../messages/impl/MessagesPresenterTest.kt | 11 +++--- .../components/dialogs/TextFieldDialog.kt | 2 ++ .../components/list/TextFieldListItem.kt | 3 +- .../src/main/res/values/temporary.xml | 3 ++ 8 files changed, 50 insertions(+), 33 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6ad00af627..7a5048d1cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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(null) } + val redactEventAction = remember { mutableStateOf>(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, + redactEventAction: MutableState>, ) = 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) { - if (event.eventId == null) { + private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState>) { + 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) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 4fd3423b6c..2073b7f847 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -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, 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. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt index c66d04564c..53f90ba7d8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt @@ -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 { 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 = 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, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 391db3c0d2..ebca98eaea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index b2682db464..b979b8fd7b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -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)) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt index 1ce241d5d9..493ad87bc3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt @@ -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 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index e149af5890..f2cace5eaf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -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, diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index c704e811ff..acb6ff8a47 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -10,4 +10,7 @@ "Collapsed" Active Inactive + "(Optional)" + "Confirm removal" + "Are you sure you wish to remove this message?"