Let user see an error dialog in the case a message fails to be sent.

Closes #7470
This commit is contained in:
Benoit Marty
2026-08-20 19:02:02 +02:00
parent d710b23513
commit c10e84923b
10 changed files with 164 additions and 4 deletions
@@ -400,9 +400,23 @@ class MessagesPresenter(
TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.ViewInTimeline -> Unit
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
} }
} }
private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
val sendHandle = targetEvent.sendhandle ?: return Unit.also {
Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
}
sendHandle.retry()
.onSuccess {
Timber.d("Succeed to add the message back to the send queue")
}
.onFailure {
Timber.e(it, "Failed to add the message back to the send queue")
}
}
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) { private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
editCaption( editCaption(
@@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@@ -429,6 +430,31 @@ fun MessagesView(
}, },
state = state.linkState, state = state.linkState,
) )
SendFailureDialogView(
sendFailureDialogState = state.timelineState.sendFailureDialogState,
onDismiss = {
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
},
onRetry = { event ->
state.eventSink(
MessagesEvent.HandleAction(
action = TimelineItemAction.RetrySending,
event = event,
)
)
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
},
onRemoveMessage = { event ->
state.eventSink(
MessagesEvent.HandleAction(
action = TimelineItemAction.Redact,
event = event,
)
)
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
},
)
} }
@Composable @Composable
@@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents(
when { when {
state.successorRoom != null -> { state.successorRoom != null -> {
SuccessorRoomBanner( SuccessorRoomBanner(
modifier = Modifier.fillMaxWidth().padding(contentPadding), modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
roomSuccessor = state.successorRoom, roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick onRoomSuccessorClick = onRoomSuccessorClick
) )
} }
state.userEventPermissions.canSendMessage -> { state.userEventPermissions.canSendMessage -> {
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
) {
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() && if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) { state.composerState.textEditorState is TextEditorState.Markdown) {
@@ -36,4 +36,7 @@ enum class TimelineItemAction(
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end), EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
// Note: this action is not used in UI for now
RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
} }
@@ -34,6 +34,9 @@ sealed interface TimelineEvent {
*/ */
sealed interface TimelineItemEvent : TimelineEvent sealed interface TimelineItemEvent : TimelineEvent
data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent
data object HideSendFailureDialog : TimelineEvent
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent
data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent
@@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
@@ -142,6 +144,7 @@ class TimelinePresenter(
val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) } val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) } val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
var sendFailureDialogState by remember { mutableStateOf<SendFailureDialogState>(SendFailureDialogState.Hidden) }
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a // Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for // [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
@@ -244,6 +247,43 @@ class TimelinePresenter(
suppressJumpToUnread.value = true suppressJumpToUnread.value = true
} }
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvent.ShowSendFailureDialog -> {
val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
when (sendStateFailure) {
is LocalEventSendState.Failed.VerifiedUser -> {
// defer to the resolveVerifiedUserSendFailureState to handle this case,
// as it will show a dialog with the right information
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
}
is LocalEventSendState.Failed.InvalidMimeType -> {
sendFailureDialogState = SendFailureDialogState.Show(
event = event.event,
message = "Invalid mime type: ${sendStateFailure.mimeType}",
)
}
LocalEventSendState.Failed.MissingMediaContent -> {
sendFailureDialogState = SendFailureDialogState.Show(
event = event.event,
message = "Missing media content",
)
}
LocalEventSendState.Failed.SendingFromUnverifiedDevice -> {
sendFailureDialogState = SendFailureDialogState.Show(
event = event.event,
message = "Sending from unverified device",
)
}
is LocalEventSendState.Failed.Unknown -> {
sendFailureDialogState = SendFailureDialogState.Show(
event = event.event,
message = sendStateFailure.error.takeIf { it.isNotBlank() } ?: "Unknown error",
)
}
}
}
is TimelineEvent.HideSendFailureDialog -> {
sendFailureDialogState = SendFailureDialogState.Hidden
}
is TimelineEvent.ComputeVerifiedUserSendFailure -> { is TimelineEvent.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
} }
@@ -408,6 +448,7 @@ class TimelinePresenter(
focusRequestState = focusRequestState.value, focusRequestState = focusRequestState.value,
messageShieldDialogData = messageShieldDialogData.value, messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
sendFailureDialogState = sendFailureDialogState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value, jumpToUnread = jumpToUnread.value,
@@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.components.MessageShieldData import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@@ -32,6 +33,7 @@ data class TimelineState(
// If not null, info will be rendered in a dialog // If not null, info will be rendered in a dialog
val messageShieldDialogData: MessageShieldData?, val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val sendFailureDialogState: SendFailureDialogState,
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean, val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState, val jumpToUnread: JumpToUnreadState,
@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
@@ -57,6 +58,7 @@ fun aTimelineState(
isLive: Boolean = true, isLive: Boolean = true,
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden,
displayThreadSummaries: Boolean = false, displayThreadSummaries: Boolean = false,
displayJumpToUnread: Boolean = false, displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
@@ -78,6 +80,7 @@ fun aTimelineState(
focusRequestState = focusRequestState, focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
sendFailureDialogState = sendFailureDialogState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread, jumpToUnread = jumpToUnread,
@@ -70,10 +70,9 @@ fun TimelineEventTimestampView(
} }
hasError -> Modifier hasError -> Modifier
.clickable( .clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = onClickLabel, onClickLabel = onClickLabel,
) { ) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event)) eventSink(TimelineEvent.ShowSendFailureDialog(event))
} }
else -> Modifier else -> Modifier
} }
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.sendfailure
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@Immutable
sealed interface SendFailureDialogState {
data object Hidden : SendFailureDialogState
data class Show(
val event: TimelineItem.Event,
val message: String,
) : SendFailureDialogState
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.sendfailure
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SendFailureDialogView(
sendFailureDialogState: SendFailureDialogState,
onDismiss: () -> Unit,
onRetry: (TimelineItem.Event) -> Unit,
onRemoveMessage: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
when (sendFailureDialogState) {
is SendFailureDialogState.Hidden -> Unit
is SendFailureDialogState.Show -> {
// Show the dialog with the message and actions
ConfirmationDialog(
modifier = modifier,
title = stringResource(id = CommonStrings.common_sending_failed),
content = sendFailureDialogState.message,
onDismiss = onDismiss,
submitText = stringResource(id = CommonStrings.action_retry),
onSubmitClick = { onRetry(sendFailureDialogState.event) },
onCancelClick = onDismiss,
thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
)
}
}
}