From c10e84923b01c40759841131d58600e2c79e0118 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:02:02 +0200 Subject: [PATCH] Let user see an error dialog in the case a message fails to be sent. Closes #7470 --- .../messages/impl/MessagesPresenter.kt | 14 +++++++ .../features/messages/impl/MessagesView.kt | 36 +++++++++++++++- .../actionlist/model/TimelineItemAction.kt | 3 ++ .../messages/impl/timeline/TimelineEvent.kt | 3 ++ .../impl/timeline/TimelinePresenter.kt | 41 ++++++++++++++++++ .../messages/impl/timeline/TimelineState.kt | 2 + .../impl/timeline/TimelineStateProvider.kt | 3 ++ .../components/TimelineEventTimestampView.kt | 3 +- .../sendfailure/SendFailureDialogState.kt | 21 ++++++++++ .../sendfailure/SendFailureDialogView.kt | 42 +++++++++++++++++++ 10 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt 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 dd3afb0ea6..4af0c05955 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 @@ -400,9 +400,23 @@ class MessagesPresenter( TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) 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) { timelineController.invokeOnCurrentTimeline { editCaption( 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 ad71ada70d..0476d0de46 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 @@ -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.event.aTimelineItemStateEventContent 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.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog @@ -429,6 +430,31 @@ fun MessagesView( }, 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 @@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents( when { state.successorRoom != null -> { SuccessorRoomBanner( - modifier = Modifier.fillMaxWidth().padding(contentPadding), + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick ) } 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). if (state.composerState.suggestions.isEmpty() && state.composerState.textEditorState is TextEditorState.Markdown) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 25e75beb99..3c64223e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -36,4 +36,7 @@ enum class TimelineItemAction( EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end), Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), 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), } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index cfc3672ca3..97608e15fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -34,6 +34,9 @@ sealed interface 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 ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 7cd0d018d2..fb64e0c096 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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.protection.TimelineProtectionEvent 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.userEventPermissions 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.timeline.ReceiptType 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.preferences.api.store.SessionPreferencesStore import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems @@ -142,6 +144,7 @@ class TimelinePresenter( val newEventState = remember { mutableStateOf(NewEventState.None) } val messageShieldDialogData: MutableState = remember { mutableStateOf(null) } + var sendFailureDialogState by remember { mutableStateOf(SendFailureDialogState.Hidden) } // Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a // [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for @@ -244,6 +247,43 @@ class TimelinePresenter( suppressJumpToUnread.value = true } 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 -> { resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event)) } @@ -408,6 +448,7 @@ class TimelinePresenter( focusRequestState = focusRequestState.value, messageShieldDialogData = messageShieldDialogData.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + sendFailureDialogState = sendFailureDialogState, displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 7836c17563..1ebc96f15f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -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.model.NewEventState 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.roomcall.api.RoomCallState 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 val messageShieldDialogData: MessageShieldData?, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, + val sendFailureDialogState: SendFailureDialogState, val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 61b5c690a4..89545b5d0b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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.aTimelineItemTextContent 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.aTypingNotificationState import io.element.android.features.roomcall.api.aStandByCallState @@ -57,6 +58,7 @@ fun aTimelineState( isLive: Boolean = true, messageShield: MessageShield? = null, resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), + sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden, displayThreadSummaries: Boolean = false, displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, @@ -78,6 +80,7 @@ fun aTimelineState( focusRequestState = focusRequestState, messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + sendFailureDialogState = sendFailureDialogState, displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index b6af359269..c82be7b10a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -70,10 +70,9 @@ fun TimelineEventTimestampView( } hasError -> Modifier .clickable( - enabled = isVerifiedUserSendFailure, onClickLabel = onClickLabel, ) { - eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event)) + eventSink(TimelineEvent.ShowSendFailureDialog(event)) } else -> Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt new file mode 100644 index 0000000000..c3d3b2d9b0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogState.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt new file mode 100644 index 0000000000..771464112a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -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) }, + ) + } + } +}