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@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-10-09 10:30:52 +02:00
committed by GitHub
parent 748669eaff
commit d8a072755f
46 changed files with 287 additions and 112 deletions
@@ -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<EventId?>
/**
* 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<Boolean>
/**
* 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<Boolean>
}
@@ -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
}
@@ -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<EventReaction>,
val receipts: ImmutableList<Receipt>,
val sender: UserId,
@@ -877,6 +877,7 @@ class RustMatrixClient(
override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow {
innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
override fun onError(roomId: String, error: ClientException) {
Timber.e(error, "Send queue error for room $roomId")
trySend(RoomId(roomId))
}
})
@@ -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<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.retrySend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map())
}
}
override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.abortSend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map())
}
}
private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
inner.fetchDetailsForEvent(eventId.value)
@@ -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)
}
@@ -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)
@@ -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<Unit> = { _, _ ->
lambdaError()
}
},
private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
) : Timeline {
var sendMessageLambda: (
body: String,
@@ -477,6 +480,14 @@ class FakeTimeline(
return getLatestEventIdResult()
}
override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
return retrySendResult(eventOrTransactionId)
}
override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
return abortSendResult(eventOrTransactionId)
}
var closeCounter = 0
private set
@@ -52,6 +52,8 @@ fun anEventTimelineItem(
isOwn: Boolean = false,
isRemote: Boolean = false,
localSendState: LocalEventSendState? = null,
editSendState: LocalEventSendState? = null,
redactionSendState: LocalEventSendState? = null,
reactions: ImmutableList<EventReaction> = persistentListOf(),
receipts: ImmutableList<Receipt> = 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,