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:
Benoit Marty
2026-09-16 16:37:51 +02:00
committed by GitHub
19 changed files with 168 additions and 21 deletions
@@ -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
@@ -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) }
}
}
@@ -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. */
@@ -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,
)
@@ -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>
@@ -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 -> }
@@ -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
@@ -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,
@@ -564,10 +564,6 @@ Czy na pewno chcesz kontynuować?"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"Jedno lub więcej z Twoich urządzeń jest niezweryfikowanych. Wyślij wiadomość mimo to lub anuluj i spróbuj ponownie po zweryfikowaniu wszystkich swoich urządzeń."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Twoja wiadomość nie została wysłana, ponieważ nie zweryfikowałeś jednego lub więcej swoich urządzeń."</string>
<string name="screen_room_change_role_administrators_or_owners_title">"Edytuj administratorów lub właścicieli"</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_error_failed_processing_media">"Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nie można pobrać danych użytkownika"</string>
<string name="screen_room_event_pill">"Wiadomość w %1$s"</string>
@@ -564,10 +564,6 @@
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"Одно или несколько ваших устройств не подтверждены. Вы можете отправить сообщение в любом случае или отменить его пока и повторить попытку позже, подтвердив все свои устройства."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств."</string>
<string name="screen_room_change_role_administrators_or_owners_title">"Редактировать владельцев и администраторов"</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_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте ещё раз."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
<string name="screen_room_event_pill">"Сообщение в %1$s"</string>
@@ -536,10 +536,6 @@
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"你有至少 1 个未经验证的设备。你可以照常发送消息,也可以暂时取消,并在验证所有设备后重试。"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"你的消息未能发送,因为你有尚未验证的设备。"</string>
<string name="screen_room_change_role_administrators_or_owners_title">"编辑管理员或所有者"</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_error_failed_processing_media">"处理要上传的媒体失败,请重试。"</string>
<string name="screen_room_error_failed_retrieving_user_details">"无法获取用户信息"</string>
<string name="screen_room_event_pill">"%1$s 中的消息"</string>
@@ -550,10 +550,6 @@ Are you sure you want to continue?"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Your message was not sent because you have not verified one or more of your devices"</string>
<string name="screen_room_change_role_administrators_or_owners_title">"Edit Admins or Owners"</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_title">"Confirm removal"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_event_pill">"Message in %1$s"</string>
+1
View File
@@ -275,6 +275,7 @@
"screen\\.room\\.timeline.*",
"error\\.sending_failure\\..*",
"screen\\.room_timeline.*",
"screen\\.room\\.confirm_removal.*",
"screen_room_typing.*",
"screen\\.image_edition\\..*",
"screen\\.media_upload.*"