mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
committed by
GitHub
parent
748669eaff
commit
d8a072755f
@@ -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 ->
|
||||
|
||||
+11
-6
@@ -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<AsyncAction<Unit>>) {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -486,7 +486,7 @@ fun MessagesView(
|
||||
onRemoveMessage = { event ->
|
||||
state.eventSink(
|
||||
MessagesEvent.HandleAction(
|
||||
action = TimelineItemAction.Redact,
|
||||
action = TimelineItemAction.AbortSending,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
|
||||
+2
-1
@@ -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),
|
||||
}
|
||||
|
||||
+2
-1
@@ -32,8 +32,9 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
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 {
|
||||
|
||||
+11
@@ -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<Boolean> {
|
||||
return currentTimelineFlow.first().retrySend(eventOrTransactionId, sendTarget)
|
||||
}
|
||||
|
||||
suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
|
||||
return currentTimelineFlow.first().abortSend(eventOrTransactionId, sendTarget)
|
||||
}
|
||||
|
||||
private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached ->
|
||||
when {
|
||||
detached.isPresent -> detached.get()
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+8
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
|
||||
+7
-4
@@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptViewState> {
|
||||
override val values: Sequence<ReadReceiptViewState>
|
||||
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<ReadReceiptVie
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = List(5) { aReadReceiptData(it) },
|
||||
),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = false),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = false),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = false),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aReadReceiptViewState(
|
||||
sendState: LocalEventSendState? = null,
|
||||
isLastOutgoingMessage: Boolean = true,
|
||||
isLastOutgoingMessage: Boolean = false,
|
||||
receipts: List<ReadReceiptData> = emptyList(),
|
||||
) = ReadReceiptViewState(
|
||||
sendState = sendState,
|
||||
|
||||
+50
-33
@@ -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
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider<ReadRec
|
||||
// Reuse the provider ReadReceiptViewStatePreviewParam
|
||||
private val readReceiptViewStateProvider = ReadReceiptViewStatePreviewParam()
|
||||
override val values: Sequence<ReadReceiptBottomSheetState> = readReceiptViewStateProvider.values
|
||||
.filter { it.sendState is LocalEventSendState.Sent }
|
||||
.filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() }
|
||||
.map { readReceiptViewState ->
|
||||
ReadReceiptBottomSheetState(
|
||||
selectedEvent = aTimelineItemEvent(
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+16
-1
@@ -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<SendTarget, LocalEventSendState>? {
|
||||
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(
|
||||
|
||||
+4
-2
@@ -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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-24
@@ -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<Unit>> { 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<Unit>> { Result.failure(AN_EXCEPTION) }
|
||||
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.failure<Boolean>(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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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<ReadReceiptData>().toImmutableList()),
|
||||
localSendState = sendState,
|
||||
editSendState = null,
|
||||
redactionSendState = null,
|
||||
inReplyTo = inReplyTo,
|
||||
threadInfo = threadInfo,
|
||||
origin = null,
|
||||
|
||||
+2
@@ -43,6 +43,8 @@ class TimelineItemGrouperTest {
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
editSendState = null,
|
||||
redactionSendState = null,
|
||||
isEditable = false,
|
||||
canBeRepliedTo = false,
|
||||
inReplyTo = null,
|
||||
|
||||
+2
@@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
||||
isOwn = false,
|
||||
isRemote = false,
|
||||
localSendState = null,
|
||||
editSendState = null,
|
||||
redactionSendState = null,
|
||||
reactions = persistentListOf(),
|
||||
receipts = persistentListOf(),
|
||||
sender = A_USER_ID,
|
||||
|
||||
+17
@@ -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>
|
||||
}
|
||||
|
||||
+28
@@ -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
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
+13
@@ -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)
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
+3
-5
@@ -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)
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user