From d8a072755f39599416469ae00a79936b4c30f328 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 9 Oct 2026 10:30:52 +0200 Subject: [PATCH] Add edit and redaction send state support (#7840) * Expose edit/redaction send states and retry/abort send from the Rust SDK Pending edits and redactions have their own send queue entries, separate from the original event. Surface them so the UI can reflect and act on them: - Add `SendTarget` (Event, Edit, Redaction, Reaction) to the matrix API, with a mapper to the Rust SDK type. - Add `editSendState` and `redactionSendState` to `EventTimelineItem` and map them from the SDK item. - Add `Timeline.retrySend` / `Timeline.abortSend`, which take a `SendTarget` so the right queued request is retried or removed. Implement them in `RustTimeline` and in `FakeTimeline`. * Track edit and redaction send states on timeline events `TimelineItem.Event` now carries `editSendState` and `redactionSendState` next to `localSendState`. - `failedToSend` is true when any of the three states is failed, so a failed edit or redaction is shown like a failed message. - `pendingSend()` returns the first pending state together with its `SendTarget`; the read receipt row, the send failure dialog and the timeline presenter use it instead of reading `localSendState` directly. - `TimelineItemReadReceiptView` is restructured so the row is only rendered when there is something to show, and the send state takes precedence over read receipts. - Update the previews provider and test fixtures for the new fields, and make `aMessageEvent`'s send state nullable. * Retry and remove failed sends through the timeline Retrying or removing a failed message used the event's `SendHandle`, which only knows about the original send. Route both through the timeline with the `SendTarget` of the pending request instead: - `TimelineController` gains `retrySend` (which also re-enables the send queue) and `abortSend`, acting on the current timeline. - `MessagesPresenter` retries via `retrySend`, and does nothing when the event has no pending send state. - Redacting something that was never sent (no event ID) or a pending edit/redaction now aborts the queued request rather than sending a redaction, and no confirmation is asked for. - Update the presenter tests accordingly. * Log send queue errors Log when the SDK reports a send queue error for a room, and when the send queue is disabled, to help diagnose messages stuck in the queue. * Make sure we display send queue errors before verification states * Fix logic for displaying read receipts and add more screenshot tests * Only use states with read receipts in `ReadReceiptBottomSheetStatePreviewParam` * Handle `LocalEventSendState = null` state just like `LocalEventSendState.Sent` * Add `TimelineItemAction.AbortSend` and use it for both: 1. Aborting sending a message/edit/redaction from the 'sending failed' dialog. 2. Abort sending a local message when selected from the timeline -> redact action. * Update screenshots --------- Co-authored-by: ElementBot --- .../android/appnav/loggedin/SendQueues.kt | 2 +- .../messages/impl/MessagesPresenter.kt | 17 ++-- .../features/messages/impl/MessagesView.kt | 2 +- .../actionlist/model/TimelineItemAction.kt | 3 +- .../model/TimelineItemActionComparator.kt | 3 +- .../impl/timeline/TimelineController.kt | 11 +++ .../impl/timeline/TimelinePresenter.kt | 4 +- .../impl/timeline/TimelineStateProvider.kt | 4 + .../components/TimelineEventTimestampView.kt | 15 ++-- .../components/TimelineItemEventRow.kt | 3 +- .../ReadReceiptViewStatePreviewParam.kt | 11 ++- .../receipt/TimelineItemReadReceiptView.kt | 83 +++++++++++-------- ...ReadReceiptBottomSheetStatePreviewParam.kt | 2 +- .../event/TimelineItemEventFactory.kt | 2 + .../impl/timeline/model/TimelineItem.kt | 17 +++- .../sendfailure/SendFailureDialogView.kt | 6 +- .../messages/impl/MessagesPresenterTest.kt | 43 +++++----- .../messages/impl/MessagesViewTest.kt | 2 +- .../impl/fixtures/MessageEventFixtures.kt | 4 +- .../groups/TimelineItemGrouperTest.kt | 2 + .../RedactedVoiceMessageManagerTest.kt | 2 + .../libraries/matrix/api/timeline/Timeline.kt | 17 ++++ .../matrix/api/timeline/item/SendTarget.kt | 28 +++++++ .../timeline/item/event/EventTimelineItem.kt | 2 + .../libraries/matrix/impl/RustMatrixClient.kt | 1 + .../matrix/impl/timeline/RustTimeline.kt | 13 +++ .../matrix/impl/timeline/SendTarget.kt | 17 ++++ .../item/event/EventTimelineItemMapper.kt | 8 +- .../matrix/test/timeline/FakeTimeline.kt | 13 ++- .../matrix/test/timeline/TimelineFixture.kt | 4 + ...msheet_ReadReceiptBottomSheet_Day_0_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_1_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_2_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_3_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_4_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_0_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_1_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_2_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_3_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_4_en.png | 4 +- ..._TimelineItemReadReceiptView_Day_10_en.png | 3 + ...t_TimelineItemReadReceiptView_Day_8_en.png | 3 + ...t_TimelineItemReadReceiptView_Day_9_en.png | 3 + ...imelineItemReadReceiptView_Night_10_en.png | 3 + ...TimelineItemReadReceiptView_Night_8_en.png | 3 + ...TimelineItemReadReceiptView_Night_9_en.png | 3 + 46 files changed, 287 insertions(+), 112 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt index 3f01f8337e..177807d501 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt @@ -41,7 +41,7 @@ class SendQueues( fun launchIn(coroutineScope: CoroutineScope) { combine( syncService.syncState, - matrixClient.sendQueueDisabledFlow(), + matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") }, ) { syncState, _ -> syncState } .debounce(SEND_QUEUES_RETRY_DELAY_MILLIS) .onEach { syncState -> 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 36d5dcaae9..5ebdd4ef19 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 @@ -86,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map @@ -441,14 +442,13 @@ class MessagesPresenter( TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) + TimelineItemAction.AbortSending -> handleActionAbortSend(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() + val (sendTarget, _) = targetEvent.pendingSend() ?: return + timelineController.retrySend(targetEvent.eventOrTransactionId, sendTarget) .onSuccess { Timber.d("Succeed to add the message back to the send queue") } @@ -538,13 +538,18 @@ class MessagesPresenter( private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState>) { val eventId = event.eventId if (eventId == null) { - // The message was never sent, so there is nobody to give a reason to. - redact(event.eventOrTransactionId, reason = null) + // The message (or its edit/redaction) was never sent, just remove it from the send queue. + handleActionAbortSend(event) } else { redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) } } + private suspend fun handleActionAbortSend(event: TimelineItem.Event) { + val sendTarget = event.pendingSend()?.first ?: SendTarget.Event + timelineController.abortSend(event.eventOrTransactionId, sendTarget).onFailure { Timber.e(it) } + } + private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) { timelineController.invokeOnCurrentTimeline { redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason) 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 50b90be650..e5c312132d 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 @@ -486,7 +486,7 @@ fun MessagesView( onRemoveMessage = { event -> state.eventSink( MessagesEvent.HandleAction( - action = TimelineItemAction.Redact, + action = TimelineItemAction.AbortSending, event = event, ) ) 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 3c64223e59..e78b2ac5e2 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 @@ -37,6 +37,7 @@ enum class TimelineItemAction( 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 + // Note: these actions are not used in UI for now RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid), + AbortSending(CommonStrings.action_cancel, CompoundDrawables.ic_compound_send_solid), } 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 2f6fa4e4e0..3f0abf1b8d 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,8 +32,9 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.ViewSource, TimelineItemAction.ReportContent, TimelineItemAction.Redact, - // Not rendered in the action list, but has to be listed here for exhaustiveness. + // Not rendered in the action list, but have to be listed here for exhaustiveness. TimelineItemAction.RetrySending, + TimelineItemAction.AbortSending, ) override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index 05f7a06054..b2639d08c6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import io.element.android.libraries.matrix.api.timeline.item.SendTarget +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.getAndUpdate @@ -134,6 +137,14 @@ class TimelineController( } } + suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return currentTimelineFlow.first().retrySend(eventOrTransactionId, sendTarget) + } + + suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return currentTimelineFlow.first().abortSend(eventOrTransactionId, sendTarget) + } + private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached -> when { detached.isPresent -> detached.get() 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 a7006985a5..1888864311 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 @@ -302,7 +302,9 @@ class TimelinePresenter( } is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData is TimelineEvent.ShowSendFailureDialog -> { - val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return + val (_, sendStateFailure) = event.event.pendingSend() ?: return + if (sendStateFailure !is LocalEventSendState.Failed) return + when (sendStateFailure) { is LocalEventSendState.Failed.VerifiedUser -> { // defer to the resolveVerifiedUserSendFailureState to handle this case, 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 7896c1fcad..5f9f56a910 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 @@ -159,6 +159,8 @@ internal fun aTimelineItemEvent( content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, sendState: LocalEventSendState? = null, + editSendState: LocalEventSendState? = null, + redactionSendState: LocalEventSendState? = null, inReplyTo: InReplyToDetails? = null, threadInfo: TimelineItemThreadInfo? = null, debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), @@ -185,6 +187,8 @@ internal fun aTimelineItemEvent( ), groupPosition = groupPosition, localSendState = sendState, + editSendState = editSendState, + redactionSendState = redactionSendState, inReplyTo = inReplyTo, threadInfo = threadInfo, origin = null, 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 d7492ca40b..7bc5cbf059 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 @@ -55,13 +55,20 @@ fun TimelineEventTimestampView( val shield = event.messageShield val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val onClickLabel = when { + hasError -> stringResource(CommonStrings.a11y_view_details) shield != null -> stringResource(CommonStrings.a11y_view_details) isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) - hasError -> stringResource(CommonStrings.a11y_view_details) else -> null } val clickableModifier = remember(event) { when { + hasError -> { + Modifier.clickable( + onClickLabel = onClickLabel, + ) { + eventSink(TimelineEvent.ShowSendFailureDialog(event)) + } + } shield != null -> { Modifier.clickable( onClickLabel = onClickLabel, @@ -69,12 +76,6 @@ fun TimelineEventTimestampView( eventSink(TimelineEvent.ShowShieldDialog(shield)) } } - hasError -> Modifier - .clickable( - onClickLabel = onClickLabel, - ) { - eventSink(TimelineEvent.ShowSendFailureDialog(event)) - } else -> Modifier } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 5d77a7ada3..2dfea52e4c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -324,9 +324,10 @@ fun TimelineItemEventRow( } // Read receipts / Send state + val sendState = event.pendingSend()?.second TimelineItemReadReceiptView( state = ReadReceiptViewState( - sendState = event.localSendState, + sendState = sendState, isLastOutgoingMessage = isLastOutgoingMessage, receipts = event.readReceiptState.receipts, ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt index 751a88cb39..0810a41dce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt @@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList class ReadReceiptViewStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aReadReceiptViewState(), - aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event), - aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), + aReadReceiptViewState(sendState = null, isLastOutgoingMessage = true), + aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = true), + aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = true), aReadReceiptViewState( sendState = LocalEventSendState.Sent(EventId("\$eventId")), receipts = List(1) { aReadReceiptData(it) }, @@ -43,12 +43,15 @@ class ReadReceiptViewStatePreviewParam : PreviewParameterProvider = emptyList(), ) = ReadReceiptViewState( sendState = sendState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 17a9b0c5eb..30b4ee2d42 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -57,43 +57,60 @@ fun TimelineItemReadReceiptView( onReadReceiptsClick: () -> Unit, modifier: Modifier = Modifier, ) { - if (state.receipts.isNotEmpty()) { - ReadReceiptsRow(modifier = modifier) { - ReadReceiptsAvatars( - receipts = state.receipts, - onClick = onReadReceiptsClick, - ) + val content: @Composable (() -> Unit)? = when (state.sendState) { + // If null, assume it's Sent and act the same way + null if state.isLastOutgoingMessage && state.receipts.isEmpty() -> { + @Composable { SentIcon() } } - } else { - when (state.sendState) { - is LocalEventSendState.Sending -> { - ReadReceiptsRow(modifier) { - Icon( - modifier = Modifier.padding(2.dp), - imageVector = CompoundIcons.Circle(), - contentDescription = stringResource(id = CommonStrings.common_sending), - tint = ElementTheme.colors.iconSecondary - ) - } - } - is LocalEventSendState.Failed -> { - // Error? The timestamp is already displayed in red - } - null, - is LocalEventSendState.Sent -> { - if (state.isLastOutgoingMessage) { - ReadReceiptsRow(modifier = modifier) { - Icon( - modifier = Modifier.padding(2.dp), - imageVector = CompoundIcons.CheckCircle(), - contentDescription = stringResource(id = CommonStrings.common_sent), - tint = ElementTheme.colors.iconSecondary - ) - } - } + is LocalEventSendState.Sending -> { + @Composable { SendingIcon() } + } + is LocalEventSendState.Failed -> { + // Error? The timestamp is already displayed in red + null + } + is LocalEventSendState.Sent if state.isLastOutgoingMessage && state.receipts.isEmpty() -> { + @Composable { SentIcon() } + } + else -> if (state.receipts.isNotEmpty()) { + @Composable { + ReadReceiptsAvatars( + receipts = state.receipts, + onClick = onReadReceiptsClick, + ) } + } else { + null } } + + // Only show the row if there is content to display + content?.let { + ReadReceiptsRow( + modifier = modifier, + content = it, + ) + } +} + +@Composable +private fun SendingIcon() { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.Circle(), + contentDescription = stringResource(id = CommonStrings.common_sending), + tint = ElementTheme.colors.iconSecondary + ) +} + +@Composable +private fun SentIcon() { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.CheckCircle(), + contentDescription = stringResource(id = CommonStrings.common_sent), + tint = ElementTheme.colors.iconSecondary + ) } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt index 86c2beb707..9577ba297a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt @@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider = readReceiptViewStateProvider.values - .filter { it.sendState is LocalEventSendState.Sent } + .filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() } .map { readReceiptViewState -> ReadReceiptBottomSheetState( selectedEvent = aTimelineItemEvent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 6cd6b66cf1..b7b072880e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -123,6 +123,8 @@ class TimelineItemEventFactory( reactionsState = currentTimelineItem.computeReactionsState(), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts), localSendState = currentTimelineItem.event.localSendState, + editSendState = currentTimelineItem.event.editSendState, + redactionSendState = currentTimelineItem.event.redactionSendState, inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), threadInfo = mappedThreadInfo, origin = currentTimelineItem.event.origin, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 1cbc2ec29c..8a108b31f9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @@ -92,6 +93,8 @@ sealed interface TimelineItem { val reactionsState: TimelineItemReactions, val readReceiptState: TimelineItemReadReceipts, val localSendState: LocalEventSendState?, + private val editSendState: LocalEventSendState?, + private val redactionSendState: LocalEventSendState?, val inReplyTo: InReplyToDetails?, val threadInfo: TimelineItemThreadInfo?, val origin: TimelineItemEventOrigin?, @@ -110,7 +113,7 @@ sealed interface TimelineItem { val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId) - val failedToSend: Boolean = localSendState is LocalEventSendState.Failed + val failedToSend: Boolean = listOf(localSendState, editSendState, redactionSendState).any { it is LocalEventSendState.Failed } val isTextMessage: Boolean = content is TimelineItemTextBasedContent @@ -143,6 +146,18 @@ sealed interface TimelineItem { get() = timelineItemDebugInfoProvider() val sendhandle: SendHandle? get() = sendHandleProvider() + + /** + * Returns the first pending send state, if any, along with the corresponding [SendTarget]. + */ + fun pendingSend(): Pair? { + return when { + localSendState != null -> SendTarget.Event to localSendState + editSendState != null -> SendTarget.Edit to editSendState + redactionSendState != null -> SendTarget.Redaction to redactionSendState + else -> null + } + } } data class GroupedEvents( 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 15ee42d575..02f720bed2 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 @@ -50,16 +50,18 @@ fun SendFailureDialogView( ) } + val (sendTarget, _) = sendFailureDialogState.event.pendingSend() ?: null to null + ConfirmationDialog( modifier = modifier, title = stringResource(id = CommonStrings.common_sending_failed), content = content, onDismiss = onDismiss, submitText = stringResource(id = CommonStrings.action_retry), - onSubmitClick = { onRetry(sendFailureDialogState.event) }, + onSubmitClick = { sendTarget?.let { onRetry(sendFailureDialogState.event) } }, onCancelClick = onDismiss, thirdButtonText = stringResource(id = CommonStrings.action_remove_message), - onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) }, + onThirdButtonClick = { sendTarget?.let { onRemoveMessage(sendFailureDialogState.event) } }, ) } } 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 17d2a2260d..3d3187af96 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 @@ -93,7 +93,6 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 -import io.element.android.libraries.matrix.test.core.FakeSendHandle 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 @@ -646,24 +645,24 @@ class MessagesPresenterTest { } @Test - fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest { + fun `present - handle action redact - a message which was never sent is removed from the send queue without asking`() = runTest { val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) - val liveTimeline = FakeTimeline() - val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } - liveTimeline.redactEventLambda = redactEventLambda + val abortSendLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } val presenter = createMessagesPresenter( - timeline = liveTimeline, + timeline = FakeTimeline(abortSendResult = abortSendLambda), coroutineDispatchers = coroutineDispatchers, ) presenter.testWithLifecycleOwner { val initialState = awaitItem() - val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID) + val localEcho = aMessageEvent( + eventId = null, + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.Unknown("Error"), + ) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho)) advanceUntilIdle() assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) - assert(redactEventLambda) - .isCalledOnce() - .with(value(localEcho.eventOrTransactionId), value(null)) + abortSendLambda.assertions().isCalledOnce().with(value(localEcho.eventOrTransactionId)) } } @@ -1177,29 +1176,27 @@ class MessagesPresenterTest { @Test fun `present - handle action retry sending`() = runTest { - val retryLambda = lambdaRecorder> { Result.success(Unit) } + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } val messageEvent = aMessageEvent( sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, ) - val presenter = createMessagesPresenter() + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) advanceUntilIdle() - retryLambda.assertions().isCalledOnce() + retryLambda.assertions().isCalledOnce().with(value(messageEvent.eventOrTransactionId)) } } @Test fun `present - handle action retry sending - failure is ignored`() = runTest { - val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.failure(AN_EXCEPTION) } val messageEvent = aMessageEvent( sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, ) - val presenter = createMessagesPresenter() + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() @@ -1210,18 +1207,16 @@ class MessagesPresenterTest { } @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() + fun `present - handle action retry sending - no pending send state, it should have no effect`() = runTest { + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } + val messageEvent = aMessageEvent(sendState = null) + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) advanceUntilIdle() - // No op! + retryLambda.assertions().isNeverCalled() } } 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..e8613d3547 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 @@ -606,7 +606,7 @@ class MessagesViewTest : RobolectricTest() { timelineEventsRecorder.clear() clickOn(CommonStrings.action_remove_message) - messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.AbortSending, timelineItem)) timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 1b9394f2ee..87dbd2119b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -43,7 +43,7 @@ internal fun aMessageEvent( content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), inReplyTo: InReplyToDetails? = null, threadInfo: TimelineItemThreadInfo? = null, - sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), + sendState: LocalEventSendState? = LocalEventSendState.Sent(AN_EVENT_ID), debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() } @@ -62,6 +62,8 @@ internal fun aMessageEvent( reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = sendState, + editSendState = null, + redactionSendState = null, inReplyTo = inReplyTo, threadInfo = threadInfo, origin = null, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index f6ef4f0fc2..b9bb13649a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -43,6 +43,8 @@ class TimelineItemGrouperTest { reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = LocalEventSendState.Sent(AN_EVENT_ID), + editSendState = null, + redactionSendState = null, isEditable = false, canBeRepliedTo = false, inReplyTo = null, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt index 9a5eda366b..ae832ade45 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf( isOwn = false, isRemote = false, localSendState = null, + editSendState = null, + redactionSendState = null, reactions = persistentListOf(), receipts = persistentListOf(), sender = A_USER_ID, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 51a8964aa0..d26d8cc00c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId @@ -423,4 +424,20 @@ interface Timeline : AutoCloseable { * Get the latest event id of the timeline, or `null` when it holds no event yet. */ suspend fun getLatestEventId(): Result + + /** + * Retries sending an event that failed to send, by putting it back in the send queue. + * + * @param eventOrTransactionId the event or transaction ID of the event to retry sending. + * @param sendTarget the target action of the send operation. + */ + suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result + + /** + * Aborts sending an event that failed to send, by removing it from the send queue. + * + * @param eventOrTransactionId the event or transaction ID of the event to abort sending. + * @param sendTarget the target action of the send operation. + */ + suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt new file mode 100644 index 0000000000..b882636e8e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt @@ -0,0 +1,28 @@ +/* + * 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.libraries.matrix.api.timeline.item + +import androidx.compose.runtime.Immutable + +/** + * Represents the target action of a send operation in a timeline. + */ +@Immutable +sealed interface SendTarget { + /** Send a new event. */ + data object Event : SendTarget + + /** Edit an existing event. */ + data object Edit : SendTarget + + /** Redact an existing event. */ + data object Redaction : SendTarget + + /** React to an existing event with the given key. */ + data class Reaction(val key: String) : SendTarget +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 8a3221677d..c2c110dd5f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -24,6 +24,8 @@ data class EventTimelineItem( val isOwn: Boolean, val isRemote: Boolean, val localSendState: LocalEventSendState?, + val editSendState: LocalEventSendState?, + val redactionSendState: LocalEventSendState?, val reactions: ImmutableList, val receipts: ImmutableList, val sender: UserId, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 70dbff1209..a9eb22cc8b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -877,6 +877,7 @@ class RustMatrixClient( override fun sendQueueDisabledFlow(): Flow = mxCallbackFlow { innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener { override fun onError(roomId: String, error: ClientException) { + Timber.e(error, "Send queue error for room $roomId") trySend(RoomId(roomId)) } }) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index c7dd260d47..d875ac0429 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.MsgType 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.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl @@ -697,6 +698,18 @@ class RustTimeline( } } + override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.retrySend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map()) + } + } + + override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.abortSend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map()) + } + } + private suspend fun fetchDetailsForEvent(eventId: EventId): Result = withContext(dispatcher) { runCatchingExceptions { inner.fetchDetailsForEvent(eventId.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt new file mode 100644 index 0000000000..889735cad9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt @@ -0,0 +1,17 @@ +/* + * 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.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.timeline.item.SendTarget + +fun SendTarget.map() = when (this) { + is SendTarget.Event -> org.matrix.rustcomponents.sdk.SendTarget.Event + is SendTarget.Edit -> org.matrix.rustcomponents.sdk.SendTarget.Edit + is SendTarget.Redaction -> org.matrix.rustcomponents.sdk.SendTarget.Redaction + is SendTarget.Reaction -> org.matrix.rustcomponents.sdk.SendTarget.Reaction(key) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index f77e690ebf..3225dffc1c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -51,6 +51,8 @@ class EventTimelineItemMapper( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState?.map(), + editSendState = editSendState?.map(), + redactionSendState = redactionSendState?.map(), reactions = reactions.map(), receipts = readReceipts.map(), sender = UserId(sender), @@ -113,11 +115,7 @@ fun RustEventSendState?.map(): LocalEventSendState? { ) } is QueueWedgeError.GenericApiError -> { - if (isRecoverable) { - LocalEventSendState.Sending.Event - } else { - LocalEventSendState.Failed.Unknown(queueWedgeError.msg) - } + LocalEventSendState.Failed.Unknown(queueWedgeError.msg) } is QueueWedgeError.InvalidMimeType -> { LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index 88dcb13a8c..07c4825cb9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MsgType 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.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler @@ -60,7 +61,9 @@ class FakeTimeline( receiptType: ReceiptType, ) -> Result = { _, _ -> lambdaError() - } + }, + private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result = { lambdaError() }, + private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result = { lambdaError() }, ) : Timeline { var sendMessageLambda: ( body: String, @@ -477,6 +480,14 @@ class FakeTimeline( return getLatestEventIdResult() } + override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return retrySendResult(eventOrTransactionId) + } + + override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return abortSendResult(eventOrTransactionId) + } + var closeCounter = 0 private set diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index da8b872ef0..c1e156caf6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -52,6 +52,8 @@ fun anEventTimelineItem( isOwn: Boolean = false, isRemote: Boolean = false, localSendState: LocalEventSendState? = null, + editSendState: LocalEventSendState? = null, + redactionSendState: LocalEventSendState? = null, reactions: ImmutableList = persistentListOf(), receipts: ImmutableList = persistentListOf(), sender: UserId = A_USER_ID, @@ -69,6 +71,8 @@ fun anEventTimelineItem( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState, + editSendState = editSendState, + redactionSendState = redactionSendState, reactions = reactions, receipts = receipts, sender = sender, diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png index c3e5011041..4dcdb1b9ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5cd23b8ab206543d9632589c9c6e3ae157846cbbeef683d187f0fb8a7475b27d -size 6698 +oid sha256:b161e07f504adbb140c80945cf9cd5b31dad4edeeb86d1b683fef4f02148a110 +size 12178 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png index 4dcdb1b9ae..63b599fe61 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b161e07f504adbb140c80945cf9cd5b31dad4edeeb86d1b683fef4f02148a110 -size 12178 +oid sha256:df393203d4535dd63962cae667253c00c19dc4b6e25c887619ff5e833066f4ba +size 17596 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png index 63b599fe61..584992b643 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df393203d4535dd63962cae667253c00c19dc4b6e25c887619ff5e833066f4ba -size 17596 +oid sha256:6f4886f895d90cbd175fab866329841b45a13890b923ee7928673698d320b60f +size 22940 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png index 584992b643..659db847cc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f4886f895d90cbd175fab866329841b45a13890b923ee7928673698d320b60f -size 22940 +oid sha256:541e50bba5a4dd70015bbc5410d8d8c9b0cd194ab119918f64baf73cefdd618d +size 27956 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png index 659db847cc..3fe4168506 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:541e50bba5a4dd70015bbc5410d8d8c9b0cd194ab119918f64baf73cefdd618d -size 27956 +oid sha256:2023632334cfbd15b399fce33cb5ac04135f371d6cb09669fd66f97bc86e8fae +size 32655 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png index 15ae66d08f..584bac4e93 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7580ba96fcc879011f591ac4bacd16d3c7cdc7db51f39cc7989c7c4dffdaa69 -size 5937 +oid sha256:eaf905401acff2b0000abc9c7a13b8034c55d12e355908dded5b1a24fe573fb8 +size 11366 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png index 584bac4e93..43aaf422f2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eaf905401acff2b0000abc9c7a13b8034c55d12e355908dded5b1a24fe573fb8 -size 11366 +oid sha256:e6dc3ec6b43eda48a6d96af60aa983c2a8c42d4da81ef48dc2bd783309de8316 +size 16469 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png index 43aaf422f2..6382583d2d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6dc3ec6b43eda48a6d96af60aa983c2a8c42d4da81ef48dc2bd783309de8316 -size 16469 +oid sha256:fa666b0e8ec6d77820c31ebeed2ba9fc9868d07e56eb77ff079b5cd2b2840224 +size 21836 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png index 6382583d2d..e9321c5510 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa666b0e8ec6d77820c31ebeed2ba9fc9868d07e56eb77ff079b5cd2b2840224 -size 21836 +oid sha256:e260c421c187424b8d139e75351aa3e654fcf28975aebc4bbcefd8f77c2d21d6 +size 27082 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png index e9321c5510..db2b180cfd 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e260c421c187424b8d139e75351aa3e654fcf28975aebc4bbcefd8f77c2d21d6 -size 27082 +oid sha256:48537a0e326d7cf1fc36060fd2831bfa3b237806a79b492368471de25f331a58 +size 32174 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_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.components.receipt_TimelineItemReadReceiptView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_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.components.receipt_TimelineItemReadReceiptView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png new file mode 100644 index 0000000000..c7b23e6596 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49d4c593002d2ed5f6ace886d9220f3d236356cf1cefd8f7c0da68177032d0bc +size 4561 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_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.components.receipt_TimelineItemReadReceiptView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_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.components.receipt_TimelineItemReadReceiptView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png new file mode 100644 index 0000000000..ac2beb8344 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bc4fe868c3386c0ff38ee807ccfd098943bdb08595372378cffc0df44956b2c +size 4448