From bc32cf1d4a14199bde376510f292c672cb8d5852 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:05 +0300 Subject: [PATCH 1/4] Let the user give a reason when removing a message The protocol carries a reason on a redaction and other clients let the sender give one, but removing a message here sent the redaction straight away with no reason and no confirmation. Removing a message now opens a dialog with an optional reason, and the redaction is only sent once it is confirmed. A blank reason is sent as none, so confirming without typing anything behaves exactly as before. A message which was never sent has nobody to give a reason to, so the send failure dialog's Remove still redacts it immediately. --- .../features/messages/impl/MessagesEvent.kt | 2 + .../messages/impl/MessagesPresenter.kt | 32 +++++++- .../features/messages/impl/MessagesState.kt | 3 + .../impl/MessagesStatePreviewParam.kt | 4 + .../features/messages/impl/MessagesView.kt | 14 ++++ .../messages/impl/MessagesPresenterTest.kt | 77 ++++++++++++++++++- 6 files changed, 128 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 4d621e417f..38aa5b75ce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -18,6 +18,8 @@ sealed interface MessagesEvent { data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent data class OnUserClicked(val user: MatrixUser) : MessagesEvent + data class ConfirmRedact(val reason: String?) : MessagesEvent + data object CancelRedact : MessagesEvent data object StopLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent 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 3ba8099ecb..6ad00af627 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 @@ -240,6 +240,8 @@ class MessagesPresenter( onPauseOrDispose {} } + val eventToRedact = remember { mutableStateOf(null) } + fun handleEvent(event: MessagesEvent) { when (event) { is MessagesEvent.HandleAction -> { @@ -250,8 +252,21 @@ class MessagesPresenter( enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, timelineProtectionState = timelineProtectionState, + eventToRedact = eventToRedact, ) } + is MessagesEvent.ConfirmRedact -> { + val target = eventToRedact.value + eventToRedact.value = null + if (target != null) { + localCoroutineScope.launch { + redact(target, event.reason?.takeIf { it.isNotBlank() }) + } + } + } + MessagesEvent.CancelRedact -> { + eventToRedact.value = null + } is MessagesEvent.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } @@ -330,6 +345,7 @@ class MessagesPresenter( hasUnreadThreads = false, ), showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread, + eventToRedact = eventToRedact.value, eventSink = ::handleEvent, ) } @@ -368,12 +384,13 @@ class MessagesPresenter( timelineProtectionState: TimelineProtectionState, enableTextFormatting: Boolean, timelineState: TimelineState, + eventToRedact: MutableState, ) = launch { when (action) { TimelineItemAction.CopyText -> handleCopyContents(targetEvent) TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent) TimelineItemAction.CopyLink -> handleCopyLink(targetEvent) - TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Redact -> handleActionRedact(targetEvent, eventToRedact) TimelineItemAction.Edit, TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) @@ -495,9 +512,18 @@ class MessagesPresenter( ) } - private suspend fun handleActionRedact(event: TimelineItem.Event) { + private suspend fun handleActionRedact(event: TimelineItem.Event, eventToRedact: MutableState) { + if (event.eventId == null) { + // The message was never sent, so there is nobody to give a reason to. + redact(event, reason = null) + } else { + eventToRedact.value = event + } + } + + private suspend fun redact(event: TimelineItem.Event, reason: String?) { timelineController.invokeOnCurrentTimeline { - redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null) + redactEvent(eventOrTransactionId = event.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 d9ec875320..4fd3423b6c 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,6 +18,7 @@ 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 @@ -61,6 +62,8 @@ 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 eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null 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 d792ab5ed4..c66d04564c 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,6 +24,7 @@ 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 @@ -92,6 +93,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider { composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) ), + aMessagesState(eventToRedact = aTimelineItemEvent()), ) } @@ -131,6 +133,7 @@ fun aMessagesState( isCurrentlySharingLiveLocationInRoom: Boolean = false, dmUserStatus: DisplayedStatus? = null, eventSink: (MessagesEvent) -> Unit = {}, + eventToRedact: TimelineItem.Event? = null, ) = MessagesState( roomId = RoomId("!id:domain"), roomName = roomName, @@ -161,6 +164,7 @@ fun aMessagesState( threads = threads, showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, dmUserStatus = dmUserStatus, + eventToRedact = eventToRedact, 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 16ca023d50..391db3c0d2 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 @@ -104,6 +104,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertM import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -455,6 +456,19 @@ fun MessagesView( state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) }, ) + + if (state.eventToRedact != null) { + TextFieldDialog( + title = stringResource(CommonStrings.action_remove_message), + label = stringResource(CommonStrings.common_reason), + placeholder = null, + value = null, + submitText = stringResource(CommonStrings.action_remove), + destructiveSubmit = true, + onSubmit = { reason -> state.eventSink(MessagesEvent.ConfirmRedact(reason)) }, + onDismissRequest = { state.eventSink(MessagesEvent.CancelRedact) }, + ) + } } @Composable 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 227088f78c..b2682db464 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 @@ -78,10 +78,12 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_CAPTION +import io.element.android.libraries.matrix.test.A_REASON import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.core.FakeSendHandle @@ -557,13 +559,86 @@ class MessagesPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) - awaitItem() + advanceUntilIdle() + val confirmingState = expectMostRecentItem() + assertThat(confirmingState.eventToRedact).isEqualTo(messageEvent) + assert(redactEventLambda).isNeverCalled() + confirmingState.eventSink(MessagesEvent.ConfirmRedact(A_REASON)) + advanceUntilIdle() + assertThat(expectMostRecentItem().eventToRedact).isNull() + assert(redactEventLambda) + .isCalledOnce() + .with(value(messageEvent.eventOrTransactionId), value(A_REASON)) + } + } + + @Test + fun `present - handle action redact - a blank reason is not sent`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val messageEvent = aMessageEvent() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) + advanceUntilIdle() + expectMostRecentItem().eventSink(MessagesEvent.ConfirmRedact(" ")) + advanceUntilIdle() + assertThat(expectMostRecentItem().eventToRedact).isNull() assert(redactEventLambda) .isCalledOnce() .with(value(messageEvent.eventOrTransactionId), value(null)) } } + @Test + fun `present - handle action redact - cancelling does not redact`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, aMessageEvent())) + advanceUntilIdle() + expectMostRecentItem().eventSink(MessagesEvent.CancelRedact) + advanceUntilIdle() + assertThat(expectMostRecentItem().eventToRedact).isNull() + assert(redactEventLambda).isNeverCalled() + } + } + + @Test + fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID) + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho)) + advanceUntilIdle() + assertThat(expectMostRecentItem().eventToRedact).isNull() + assert(redactEventLambda) + .isCalledOnce() + .with(value(localEcho.eventOrTransactionId), value(null)) + } + } + @Test fun `present - handle action report content`() = runTest { val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> } From 1f4d8b1733ddb5b292195f88774a6f2d31e32aa0 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:14:52 +0300 Subject: [PATCH 2/4] Update screenshots --- .../images/features.messages.impl_MessagesView_Day_12_en.png | 3 +++ .../images/features.messages.impl_MessagesView_Night_12_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png new file mode 100644 index 0000000000..187081f077 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0291b33e495c88fc86b90278dbaecf4811c04b17659422c4e3ac43d10817b011 +size 47592 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png new file mode 100644 index 0000000000..af6d9fa1fb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b80dd96314a0f61f1a8a92f9bd53582cb20de2f7835a825f8ea4cfa37859e06 +size 42085 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 3/4] 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?" From 991e801fd81ff4006067498e13c8e7bb31b1f550 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:28:09 +0300 Subject: [PATCH 4/4] Update screenshots --- .../images/features.messages.impl_MessagesView_Day_12_en.png | 4 ++-- .../features.messages.impl_MessagesView_Night_12_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png index 187081f077..11856752ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0291b33e495c88fc86b90278dbaecf4811c04b17659422c4e3ac43d10817b011 -size 47592 +oid sha256:25b37aa1c7ff867bcb9a3723436eff0209720476757899125226a9a6f854afad +size 53178 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png index af6d9fa1fb..ddfdf3dcd2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7b80dd96314a0f61f1a8a92f9bd53582cb20de2f7835a825f8ea4cfa37859e06 -size 42085 +oid sha256:c9e395cd1f389f2a341539cc72923361a542a5aafe037859a57190448f484a53 +size 47216