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 6a019d865b..812ec704f0 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 @@ -58,6 +58,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 @@ -84,6 +85,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 @@ -241,6 +243,8 @@ class MessagesPresenter( onPauseOrDispose {} } + val redactEventAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + fun handleEvent(event: MessagesEvent) { when (event) { is MessagesEvent.HandleAction -> { @@ -251,8 +255,21 @@ class MessagesPresenter( enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, timelineProtectionState = timelineProtectionState, + redactEventAction = redactEventAction, ) } + is MessagesEvent.ConfirmRedact -> { + val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction + redactEventAction.value = AsyncAction.Uninitialized + if (confirming != null) { + localCoroutineScope.launch { + redact(confirming.eventId.toEventOrTransactionId(), event.reason?.takeIf { it.isNotBlank() }) + } + } + } + MessagesEvent.CancelRedact -> { + redactEventAction.value = AsyncAction.Uninitialized + } is MessagesEvent.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } @@ -331,6 +348,7 @@ class MessagesPresenter( hasUnreadThreads = false, ), showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread, + redactEventAction = redactEventAction.value, eventSink = ::handleEvent, ) } @@ -369,12 +387,13 @@ class MessagesPresenter( timelineProtectionState: TimelineProtectionState, enableTextFormatting: Boolean, timelineState: TimelineState, + redactEventAction: MutableState>, ) = launch { when (action) { TimelineItemAction.CopyText -> handleCopyContents(targetEvent) TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent) TimelineItemAction.CopyLink -> handleCopyLink(targetEvent) - TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Redact -> handleActionRedact(targetEvent, redactEventAction) TimelineItemAction.Edit, TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) @@ -496,9 +515,19 @@ class MessagesPresenter( ) } - private suspend fun handleActionRedact(event: TimelineItem.Event) { + 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.eventOrTransactionId, reason = null) + } else { + redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) + } + } + + private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) { timelineController.invokeOnCurrentTimeline { - redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null) + 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 d9ec875320..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 @@ -21,9 +21,11 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot 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 @@ -61,6 +63,7 @@ data class MessagesState( val successorRoom: SuccessorRoom?, val threads: Threads, val showLiveLocationShareBanner: Boolean, + val redactEventAction: AsyncAction, val eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null @@ -69,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 d792ab5ed4..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 @@ -41,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 @@ -92,6 +94,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider { composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) ), + aMessagesState(redactEventAction = MessagesState.ConfirmingRedaction(EventId("\$anEventId"))), ) } @@ -131,6 +134,7 @@ fun aMessagesState( isCurrentlySharingLiveLocationInRoom: Boolean = false, dmUserStatus: DisplayedStatus? = null, eventSink: (MessagesEvent) -> Unit = {}, + redactEventAction: AsyncAction = AsyncAction.Uninitialized, ) = MessagesState( roomId = RoomId("!id:domain"), roomName = roomName, @@ -161,6 +165,7 @@ fun aMessagesState( threads = threads, showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, dmUserStatus = dmUserStatus, + 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 16ca023d50..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 @@ -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,20 @@ fun MessagesView( state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) }, ) + + if (state.redactEventAction is MessagesState.ConfirmingRedaction) { + TextFieldDialog( + 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, + 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 4c0abc0a34..562e29b475 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 @@ -78,10 +79,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 +560,86 @@ class MessagesPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) - awaitItem() + advanceUntilIdle() + val confirmingState = expectMostRecentItem() + assertThat(confirmingState.redactEventAction).isEqualTo(MessagesState.ConfirmingRedaction(messageEvent.eventId!!)) + assert(redactEventLambda).isNeverCalled() + confirmingState.eventSink(MessagesEvent.ConfirmRedact(A_REASON)) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + 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().redactEventAction).isEqualTo(AsyncAction.Uninitialized) 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().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + 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().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda) + .isCalledOnce() + .with(value(localEcho.eventOrTransactionId), value(null)) + } + } + @Test fun `present - handle action report content`() = runTest { val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> } 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?" 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..11856752ae --- /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: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 new file mode 100644 index 0000000000..ddfdf3dcd2 --- /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:c9e395cd1f389f2a341539cc72923361a542a5aafe037859a57190448f484a53 +size 47216