mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge pull request #7679 from element-hq/feature/bma/messageDeletionConfirmation
Let the user give an optional reason when removing a message
This commit is contained in:
+2
@@ -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
|
||||
|
||||
+32
-3
@@ -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<Unit>>(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<AsyncAction<Unit>>,
|
||||
) = 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<AsyncAction<Unit>>) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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<Unit>,
|
||||
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. */
|
||||
|
||||
+5
@@ -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<MessagesState> {
|
||||
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<Unit> = 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,
|
||||
)
|
||||
|
||||
|
||||
+15
@@ -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(R.string.screen_room_confirm_removal_title),
|
||||
content = stringResource(R.string.screen_room_confirm_removal_message),
|
||||
placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder_android),
|
||||
supportingText = stringResource(R.string.screen_room_confirm_removal_reason_supporting_text),
|
||||
value = null,
|
||||
submitText = stringResource(CommonStrings.action_remove),
|
||||
destructiveSubmit = true,
|
||||
onSubmit = { reason -> state.eventSink(MessagesEvent.ConfirmRedact(reason)) },
|
||||
onDismissRequest = { state.eventSink(MessagesEvent.CancelRedact) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Ankieta"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Formatowanie tekstu"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Czy na pewno chcesz usunąć wiadomość?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Powód (opcjonalne)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Powód…"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Potwierdź usunięcie"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Historia wiadomości jest obecnie niedostępna."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Czy chcesz zaprosić ich z powrotem?"</string>
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Поделиться местоположением"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Опрос"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Вы уверены, что хотите удалить это сообщение?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Причина (необязательно)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Введите…"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Подтвердите удаление"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string>
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
<string name="screen_room_attachment_source_location">"共享位置"</string>
|
||||
<string name="screen_room_attachment_source_poll">"投票"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"文本格式化"</string>
|
||||
<string name="screen_room_confirm_removal_message">"确定要移除此消息?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"理由(可选)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"输入…"</string>
|
||||
<string name="screen_room_confirm_removal_title">"确认移除"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"消息历史记录当前不可用。"</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"消息历史在此房间不可用。请验证此设备以查看。"</string>
|
||||
<string name="screen_room_invite_again_alert_message">"你想邀请他们回来吗?"</string>
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
<string name="screen_room_attachment_source_location">"Share location"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Poll"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Text Formatting"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Are you sure you wish to remove this message?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Reason (optional)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Enter…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Reason…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Confirm removal"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Message history is unavailable in this room. Verify this device to see your message history."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
|
||||
|
||||
+77
-1
@@ -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
|
||||
@@ -556,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.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 -> }
|
||||
|
||||
Reference in New Issue
Block a user