From c10e84923b01c40759841131d58600e2c79e0118 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:02:02 +0200 Subject: [PATCH 1/9] 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) }, + ) + } + } +} From 27d3f3dabcce7a69329eeb95efc6a540d2f27900 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:06:07 +0200 Subject: [PATCH 2/9] Add preview for the new dialog --- .../SendFailureDialogStateProvider.kt | 22 +++++++++++++++++++ .../sendfailure/SendFailureDialogView.kt | 15 +++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt new file mode 100644 index 0000000000..b6730f0392 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt @@ -0,0 +1,22 @@ +/* + * 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.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent + +class SendFailureDialogStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SendFailureDialogState.Hidden, + SendFailureDialogState.Show( + event = aTimelineItemEvent(), + message = "Failed to send message", + ) + ) +} 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 index 771464112a..0054880a76 100644 --- 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 @@ -10,8 +10,10 @@ 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 androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -40,3 +42,16 @@ fun SendFailureDialogView( } } } + +@PreviewsDayNight +@Composable +fun SendFailureDialogViewPreview( + @PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState, +) { + SendFailureDialogView( + sendFailureDialogState = sendFailureDialogState, + onDismiss = {}, + onRetry = {}, + onRemoveMessage = {} + ) +} From 3f2a1d95fcef783a7d03805d25b121fb665ef68d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 19:41:02 +0200 Subject: [PATCH 3/9] Fix issues and add missing text. --- .../model/TimelineItemActionComparator.kt | 2 + .../components/TimelineEventTimestampView.kt | 5 +- .../sendfailure/SendFailureDialogView.kt | 7 +- .../messages/impl/MessagesPresenterTest.kt | 52 +++++++++ .../messages/impl/MessagesViewTest.kt | 67 +++++++++++ .../impl/timeline/TimelinePresenterTest.kt | 110 +++++++++++++++++- .../impl/timeline/TimelineViewTest.kt | 41 +++++++ .../sendfailure/SendFailureDialogViewTest.kt | 105 +++++++++++++++++ 8 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 0a0d9b17af..2f6fa4e4e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.ViewSource, TimelineItemAction.ReportContent, TimelineItemAction.Redact, + // Not rendered in the action list, but has to be listed here for exhaustiveness. + TimelineItemAction.RetrySending, ) override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { 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 c82be7b10a..f124d45d99 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 @@ -56,10 +56,11 @@ fun TimelineEventTimestampView( val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val onClickLabel = when { shield != null -> stringResource(CommonStrings.a11y_view_details) - hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) + isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) + hasError -> stringResource(CommonStrings.a11y_view_details) else -> null } - val clickableModifier = remember(shield, hasError) { + val clickableModifier = remember(event) { when { shield != null -> { Modifier.clickable( 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 index 0054880a76..ecab806857 100644 --- 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.ui.strings.CommonStrings @@ -45,13 +46,13 @@ fun SendFailureDialogView( @PreviewsDayNight @Composable -fun SendFailureDialogViewPreview( +internal fun SendFailureDialogViewPreview( @PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState, -) { +) = ElementPreview { SendFailureDialogView( sendFailureDialogState = sendFailureDialogState, onDismiss = {}, onRetry = {}, - onRemoveMessage = {} + onRemoveMessage = {}, ) } 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 0a0d310b16..227088f78c 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 @@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -83,6 +84,7 @@ 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_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -1043,6 +1045,56 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action retry sending`() = runTest { + val retryLambda = lambdaRecorder> { Result.success(Unit) } + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + retryLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - handle action retry sending - failure is ignored`() = runTest { + val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + retryLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest { + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("Error"), + sendHandleProvider = { null }, + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) + advanceUntilIdle() + // No op! + } + } + @Test fun `present - handle action view in timeline, it should have no effect`() = runTest { val messageEvent = aMessageEvent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 1350f1dfa0..2a6353de87 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -55,12 +55,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.user.MatrixUser @@ -558,6 +560,71 @@ class MessagesViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem)) } + @Test + fun `clicking on retry in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder() + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_retry) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, timelineItem)) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + + @Test + fun `clicking on remove message in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder() + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_remove_message) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + + @Test + fun `cancelling the send failure dialog emits the expected Event`() = runAndroidComposeUiTest { + val messagesEventsRecorder = EventsRecorder(expectEvents = false) + val timelineEventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error")) + setMessagesView( + state = aMessagesState( + timelineState = aTimelineState( + sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + eventSink = timelineEventsRecorder, + ), + eventSink = messagesEventsRecorder, + ), + ) + // Clear initial 'LoadMore' event emitted when setting the state + timelineEventsRecorder.clear() + + clickOn(CommonStrings.action_cancel) + timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) + } + @Test fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest { val aUnicode = "🙈" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3b0794305f..3e4e8bc0ae 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -12,6 +12,8 @@ import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.impl.FakeMessagesNavigator +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -21,6 +23,7 @@ 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.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager @@ -45,6 +48,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem 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.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent @@ -1390,6 +1394,109 @@ class TimelinePresenterTest { } } + @Test + fun `present - show and hide send failure dialog`() = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error")) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + awaitItem().also { state -> + assertThat(state.sendFailureDialogState).isEqualTo( + SendFailureDialogState.Show(event = event, message = "An error") + ) + state.eventSink(TimelineEvent.HideSendFailureDialog) + } + awaitItem().also { state -> + assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + } + } + } + + @Test + fun `present - show send failure dialog - unknown error with a blank reason`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.Unknown(error = ""), + expectedMessage = "Unknown error", + ) + } + + @Test + fun `present - show send failure dialog - invalid mime type`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"), + expectedMessage = "Invalid mime type: invalid/mimeType", + ) + } + + @Test + fun `present - show send failure dialog - missing media content`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.MissingMediaContent, + expectedMessage = "Missing media content", + ) + } + + @Test + fun `present - show send failure dialog - sending from unverified device`() { + assertSendFailureDialogMessage( + sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice, + expectedMessage = "Sending from unverified device", + ) + } + + private fun assertSendFailureDialogMessage( + sendState: LocalEventSendState.Failed, + expectedMessage: String, + ) = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = sendState) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + assertThat(awaitItem().sendFailureDialogState).isEqualTo( + SendFailureDialogState.Show(event = event, message = expectedMessage) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest { + val resolveEventSinkRecorder = lambdaRecorder { } + val presenter = createTimelinePresenter( + resolveVerifiedUserSendFailurePresenter = { + aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder) + }, + ) + val event = aMessageEvent( + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + // The generic dialog is not displayed, the dedicated one is. + assertThat(consumeItemsUntilTimeout()).isEmpty() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + resolveEventSinkRecorder.assertions() + .isCalledOnce() + .with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event))) + } + } + + @Test + fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest { + val presenter = createTimelinePresenter() + val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) + assertThat(consumeItemsUntilTimeout()).isEmpty() + assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden) + } + } + @Test fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { val timeline = FakeTimeline( @@ -1589,6 +1696,7 @@ class TimelinePresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, + resolveVerifiedUserSendFailurePresenter: Presenter = { aResolveVerifiedUserSendFailureState() }, ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1602,7 +1710,7 @@ class TimelinePresenterTest { sessionPreferencesStore = sessionPreferencesStore, timelineItemIndexer = timelineItemIndexer, timelineController = TimelineController(room, timeline), - resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, + resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index f733f116d6..ac84d48c95 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId 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.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType @@ -178,6 +179,46 @@ class TimelineViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) } + @Test + fun `clicking on the send failure indicator emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent( + content = aTimelineItemTextContent(), + sendState = LocalEventSendState.Failed.Unknown("Error"), + ) + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(timelineItem), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + val contentDescription = activity!!.getString(CommonStrings.common_sending_failed) + onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem)) + } + + @Test + fun `clicking on the send failure indicator of a verified user failure emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val timelineItem = aTimelineItemEvent( + content = aTimelineItemTextContent(), + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf(timelineItem), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + val contentDescription = activity!!.getString(CommonStrings.common_sending_failed) + onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem)) + } + @Test fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt new file mode 100644 index 0000000000..204d367b0f --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt @@ -0,0 +1,105 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.messages.impl.timeline.sendfailure + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.setSafeContent +import org.junit.Test + +class SendFailureDialogViewTest : RobolectricTest() { + @Test + fun `hidden state renders nothing`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = SendFailureDialogState.Hidden, + ) + onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertDoesNotExist() + } + + @Test + fun `show state renders the failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow(message = "A failure reason"), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertExists() + onNodeWithText("A failure reason").assertExists() + } + + @Test + fun `clicking on retry invokes the expected callback`() = runAndroidComposeUiTest { + val state = aSendFailureDialogStateShow() + ensureCalledOnceWithParam(state.event) { callback -> + setSendFailureDialogView( + sendFailureDialogState = state, + onRetry = callback, + ) + clickOn(CommonStrings.action_retry) + } + } + + @Test + fun `clicking on remove message invokes the expected callback`() = runAndroidComposeUiTest { + val state = aSendFailureDialogStateShow() + ensureCalledOnceWithParam(state.event) { callback -> + setSendFailureDialogView( + sendFailureDialogState = state, + onRemoveMessage = callback, + ) + clickOn(CommonStrings.action_remove_message) + } + } + + @Test + fun `clicking on cancel invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow(), + onDismiss = callback, + ) + clickOn(CommonStrings.action_cancel) + } + } + + private fun aSendFailureDialogStateShow( + message: String = "A failure reason", + ) = SendFailureDialogState.Show( + event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown(message)), + message = message, + ) + + private fun AndroidComposeUiTest.setSendFailureDialogView( + sendFailureDialogState: SendFailureDialogState, + onDismiss: () -> Unit = EnsureNeverCalled(), + onRetry: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onRemoveMessage: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + ) { + setSafeContent { + SendFailureDialogView( + sendFailureDialogState = sendFailureDialogState, + onDismiss = onDismiss, + onRetry = onRetry, + onRemoveMessage = onRemoveMessage, + ) + } + } +} From babed8eb1f19cb0ce4d2daa28c8aaabb9d1aeaa3 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 20 Aug 2026 18:01:33 +0000 Subject: [PATCH 4/9] Update screenshots --- ...pl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_0_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_1_en.png | 3 +++ .../images/features.messages.impl_MessagesViewA11y_en.png | 4 ++-- 5 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png new file mode 100644 index 0000000000..701a10ceb5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65e30408e30c4a6d28900b60ec4b4b43fec5343dcd3487f53cafc8eb58e94a17 +size 21043 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png new file mode 100644 index 0000000000..f0b6a98753 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88e8f1e5adb178387d6a151cc56ed530ff15c9e4d6a7deebddd12ba1f8ea7e71 +size 19642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png index 78a758b1e8..3c357233b9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:996251b2e0e0a28ec167ec137f913ed5bb6f21b443bbf8151bc3203ad853b667 -size 131374 +oid sha256:95c5746de02c584536b1178614d15460bbdda633ddf44485261007618bd99f50 +size 132268 From 25989135a8cc72caf80431e7d433d3cbec77d628 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 09:35:35 +0200 Subject: [PATCH 5/9] Add error content to Localazy --- .../impl/timeline/TimelinePresenter.kt | 12 ++++++---- .../sendfailure/SendFailureDialogState.kt | 11 ++++++++- .../SendFailureDialogStateProvider.kt | 18 ++++++++++++++- .../sendfailure/SendFailureDialogView.kt | 23 ++++++++++++++++++- .../impl/src/main/res/values/localazy.xml | 5 ++++ tools/localazy/config.json | 1 + 6 files changed, 63 insertions(+), 7 deletions(-) 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 fb64e0c096..6d5e0b2bf1 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 @@ -258,25 +258,29 @@ class TimelinePresenter( is LocalEventSendState.Failed.InvalidMimeType -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = "Invalid mime type: ${sendStateFailure.mimeType}", + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType), ) } LocalEventSendState.Failed.MissingMediaContent -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = "Missing media content", + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, ) } LocalEventSendState.Failed.SendingFromUnverifiedDevice -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = "Sending from unverified device", + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, ) } is LocalEventSendState.Failed.Unknown -> { sendFailureDialogState = SendFailureDialogState.Show( event = event.event, - message = sendStateFailure.error.takeIf { it.isNotBlank() } ?: "Unknown error", + sendFailureType = if (sendStateFailure.error.isNotBlank()) { + SendFailureDialogState.SendFailureType.Error(sendStateFailure.error) + } else { + SendFailureDialogState.SendFailureType.Unknown + } ) } } 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 index c3d3b2d9b0..02da5a6503 100644 --- 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 @@ -16,6 +16,15 @@ sealed interface SendFailureDialogState { data class Show( val event: TimelineItem.Event, - val message: String, + val sendFailureType: SendFailureType, ) : SendFailureDialogState + + @Immutable + sealed interface SendFailureType { + data class InvalidMimeType(val mimeType: String) : SendFailureType + data object MissingMediaContent : SendFailureType + data object SendingFromUnverifiedDevice : SendFailureType + data class Error(val message: String) : SendFailureType + data object Unknown : SendFailureType + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt index b6730f0392..55c894c0d9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogStateProvider.kt @@ -16,7 +16,23 @@ class SendFailureDialogStateProvider : PreviewParameterProvider Unit is SendFailureDialogState.Show -> { // Show the dialog with the message and actions + val content = when (sendFailureDialogState.sendFailureType) { + is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource( + id = R.string.error_sending_failure_invalid_mime_type, + sendFailureDialogState.sendFailureType.mimeType, + ) + is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource( + id = R.string.error_sending_failure_missing_media_content, + ) + is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource( + id = R.string.error_sending_failure_sending_from_unverified_device, + ) + is SendFailureDialogState.SendFailureType.Error -> stringResource( + id = R.string.error_sending_failure_other, + sendFailureDialogState.sendFailureType.message + ) + is SendFailureDialogState.SendFailureType.Unknown -> stringResource( + id = R.string.error_sending_failure_unknown, + ) + } + ConfirmationDialog( modifier = modifier, title = stringResource(id = CommonStrings.common_sending_failed), - content = sendFailureDialogState.message, + content = content, onDismiss = onDismiss, submitText = stringResource(id = CommonStrings.action_retry), onSubmitClick = { onRetry(sendFailureDialogState.event) }, diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 556ab00ee0..e29b83af41 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -7,6 +7,11 @@ "Encrypted by an unknown or deleted device." "Encrypted by a device not verified by its owner." "Encrypted by an unverified user." + "Invalid mime type: %s" + "Missing media content" + "Error details: %s" + "Sending from unverified device" + "Unknown error" "Flip image horizontally" "Flipped horizontally" "Original" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 8a7790be1e..dddb21a6cf 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -271,6 +271,7 @@ "screen_room_retry.*", "screen_room_timeline.*", "screen\\.room\\.timeline.*", + "error\\.sending_failure\\..*", "screen\\.room_timeline.*", "screen_room_typing.*", "screen\\.image_edition\\..*", From eb37c7bd55d967d2edccc439d8b5f3c2861ddc65 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 09:49:23 +0200 Subject: [PATCH 6/9] Update test with Claude --- .../messages/impl/MessagesViewTest.kt | 15 ++++- .../impl/timeline/TimelinePresenterTest.kt | 27 +++++---- .../sendfailure/SendFailureDialogViewTest.kt | 58 ++++++++++++++++--- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 2a6353de87..677b2ac10b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -568,7 +568,10 @@ class MessagesViewTest : RobolectricTest() { setMessagesView( state = aMessagesState( timelineState = aTimelineState( - sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), eventSink = timelineEventsRecorder, ), eventSink = messagesEventsRecorder, @@ -590,7 +593,10 @@ class MessagesViewTest : RobolectricTest() { setMessagesView( state = aMessagesState( timelineState = aTimelineState( - sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), eventSink = timelineEventsRecorder, ), eventSink = messagesEventsRecorder, @@ -612,7 +618,10 @@ class MessagesViewTest : RobolectricTest() { setMessagesView( state = aMessagesState( timelineState = aTimelineState( - sendFailureDialogState = SendFailureDialogState.Show(event = timelineItem, message = "Error"), + sendFailureDialogState = SendFailureDialogState.Show( + event = timelineItem, + sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"), + ), eventSink = timelineEventsRecorder, ), eventSink = messagesEventsRecorder, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3e4e8bc0ae..45ca98e378 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -1404,7 +1404,10 @@ class TimelinePresenterTest { initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) awaitItem().also { state -> assertThat(state.sendFailureDialogState).isEqualTo( - SendFailureDialogState.Show(event = event, message = "An error") + SendFailureDialogState.Show( + event = event, + sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"), + ) ) state.eventSink(TimelineEvent.HideSendFailureDialog) } @@ -1416,39 +1419,39 @@ class TimelinePresenterTest { @Test fun `present - show send failure dialog - unknown error with a blank reason`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.Unknown(error = ""), - expectedMessage = "Unknown error", + expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown, ) } @Test fun `present - show send failure dialog - invalid mime type`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"), - expectedMessage = "Invalid mime type: invalid/mimeType", + expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"), ) } @Test fun `present - show send failure dialog - missing media content`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.MissingMediaContent, - expectedMessage = "Missing media content", + expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, ) } @Test fun `present - show send failure dialog - sending from unverified device`() { - assertSendFailureDialogMessage( + assertSendFailureType( sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice, - expectedMessage = "Sending from unverified device", + expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, ) } - private fun assertSendFailureDialogMessage( + private fun assertSendFailureType( sendState: LocalEventSendState.Failed, - expectedMessage: String, + expectedSendFailureType: SendFailureDialogState.SendFailureType, ) = runTest { val presenter = createTimelinePresenter() val event = aMessageEvent(sendState = sendState) @@ -1456,7 +1459,7 @@ class TimelinePresenterTest { val initialState = awaitFirstItem() initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event)) assertThat(awaitItem().sendFailureDialogState).isEqualTo( - SendFailureDialogState.Show(event = event, message = expectedMessage) + SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType) ) cancelAndIgnoreRemainingEvents() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt index 204d367b0f..05388e7227 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogViewTest.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam @@ -37,12 +39,54 @@ class SendFailureDialogViewTest : RobolectricTest() { } @Test - fun `show state renders the failure reason`() = runAndroidComposeUiTest { + fun `show state renders the error failure reason`() = runAndroidComposeUiTest { setSendFailureDialogView( - sendFailureDialogState = aSendFailureDialogStateShow(message = "A failure reason"), + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.Error("A failure reason"), + ), ) - onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertExists() - onNodeWithText("A failure reason").assertExists() + assertNodeWithTextIsDisplayed(CommonStrings.common_sending_failed) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_other, "A failure reason")).assertExists() + } + + @Test + fun `show state renders the invalid mime type failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"), + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_invalid_mime_type, "invalid/mimeType")).assertExists() + } + + @Test + fun `show state renders the missing media content failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_missing_media_content)).assertExists() + } + + @Test + fun `show state renders the sending from unverified device failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_sending_from_unverified_device)).assertExists() + } + + @Test + fun `show state renders the unknown failure reason`() = runAndroidComposeUiTest { + setSendFailureDialogView( + sendFailureDialogState = aSendFailureDialogStateShow( + sendFailureType = SendFailureDialogState.SendFailureType.Unknown, + ), + ) + onNodeWithText(activity!!.getString(R.string.error_sending_failure_unknown)).assertExists() } @Test @@ -81,10 +125,10 @@ class SendFailureDialogViewTest : RobolectricTest() { } private fun aSendFailureDialogStateShow( - message: String = "A failure reason", + sendFailureType: SendFailureDialogState.SendFailureType = SendFailureDialogState.SendFailureType.Unknown, ) = SendFailureDialogState.Show( - event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown(message)), - message = message, + event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("")), + sendFailureType = sendFailureType, ) private fun AndroidComposeUiTest.setSendFailureDialogView( From 392d73826a7e0720458099139e5167228697c5fd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 09:54:06 +0200 Subject: [PATCH 7/9] Add instruction about screenshot test, so that AI does not try to record them. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d206be98c6..2c6cc9d60c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ PRs must meet these rules. - Use `@PreviewsDayNight` for consistency. - Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states. - Wrap previews in `ElementPreview { ... }`. +- When writing tests, never try to record the screenshots, the CI will do it. --- From df2ea586bddbff4e3327c5e3a2b4ec3a46b6eb04 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 21 Aug 2026 08:13:54 +0000 Subject: [PATCH 8/9] Update screenshots --- ...pl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png | 4 ++-- ...pl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png | 3 +++ ...pl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_1_en.png | 4 ++-- ....timeline.sendfailure_SendFailureDialogView_Night_2_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_3_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_4_en.png | 3 +++ ....timeline.sendfailure_SendFailureDialogView_Night_5_en.png | 3 +++ 10 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png index 701a10ceb5..98a271f16e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65e30408e30c4a6d28900b60ec4b4b43fec5343dcd3487f53cafc8eb58e94a17 -size 21043 +oid sha256:3412f2c40e8bff9f152ad856050415a16b76376d46eda582168f75e126485676 +size 21803 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png new file mode 100644 index 0000000000..de396af500 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec927c9bd1b1e0000521d476f3141a6d2912b558ee1fddd089936afd59ac4d27 +size 20905 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png new file mode 100644 index 0000000000..3edc968290 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:346138ce103462468652d51f969514a17b096795163ce1d2be2a99adf61471f4 +size 21766 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png new file mode 100644 index 0000000000..74989de70a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1afe321e7c1f2a57d3317e952975655f89a4acae1386b425be089e03cc03a8d9 +size 22697 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png new file mode 100644 index 0000000000..7bd3c0d04f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aba767b7b1380ca1fcd49498376128b5e537da7f15b2512c6f6cf409b3a3f9df +size 19567 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png index f0b6a98753..b1f589b037 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88e8f1e5adb178387d6a151cc56ed530ff15c9e4d6a7deebddd12ba1f8ea7e71 -size 19642 +oid sha256:05211670ecfed13426dae281438c0f51d4d98b1d33178ac1e3753bc36855b952 +size 20362 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png new file mode 100644 index 0000000000..c2ff59b834 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9417e640c72190934981c3d7f29e9b60b241c69bcab800af7908b6b5d097089a +size 19453 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png new file mode 100644 index 0000000000..7aa92967f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e38f946dfda1008370b1171f4daffb2afea93b6f8ba9d8e816b8a12124467087 +size 20365 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png new file mode 100644 index 0000000000..c5e55379a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3996b007ecb0d6ccbba7b21d35a089b5ea5f0a314fe8a09b9f88e3829272a715 +size 21290 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png new file mode 100644 index 0000000000..5270ee0b32 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.sendfailure_SendFailureDialogView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d7b95deec3bb0c5b542885a8900144233434561b5488e50c1560cdb287b9a61 +size 18154 From a59997336de0f801ee13c71398c79829fcc5b779 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 10:17:45 +0200 Subject: [PATCH 9/9] Suppress Large Class warning on test. --- .../element/android/features/messages/impl/MessagesViewTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 677b2ac10b..f662da0eb0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -90,6 +90,7 @@ import org.junit.Test import org.robolectric.annotation.Config import kotlin.time.Duration.Companion.milliseconds +@Suppress("LargeClass") class MessagesViewTest : RobolectricTest() { @Test fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {