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?"