Fix issues and add missing text.

This commit is contained in:
Benoit Marty
2026-08-20 19:41:02 +02:00
parent 27d3f3dabc
commit 3f2a1d95fc
8 changed files with 383 additions and 6 deletions
@@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
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 {
@@ -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(
@@ -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 = {},
)
}
@@ -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<Unit>> { 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<Unit>> { 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(
@@ -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<MessagesEvent>()
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
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<MessagesEvent>()
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
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<MessagesEvent>(expectEvents = false)
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
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 = "🙈"
@@ -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<ResolveVerifiedUserSendFailureEvent, Unit> { }
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<TimelineProtectionState> = { aTimelineProtectionState() },
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { 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,
@@ -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<TimelineEvent>()
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<TimelineEvent>()
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<TimelineEvent>()
@@ -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<ComponentActivity>.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,
)
}
}
}