[Message Actions] Retry sending failed messages (#596)

* Add `RetrySendMessageMenu` to retry sending failed messages or removing its local echo.

* Fix initial event being retrieved, not the updated one

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-06-15 11:27:37 +02:00 committed by GitHub
parent 7aca5c904c
commit 31555c4bd0
41 changed files with 641 additions and 37 deletions

1
changelog.d/487.feature Normal file
View file

@ -0,0 +1 @@
Add menu to retry sending failed messages or delete their local echoes.

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -65,6 +66,7 @@ class MessagesPresenter @Inject constructor(
private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
@ -77,6 +79,7 @@ class MessagesPresenter @Inject constructor(
val composerState = composerPresenter.present()
val timelineState = timelinePresenter.present()
val actionListState = actionListPresenter.present()
val retryState = retrySendMenuPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
val roomName: MutableState<String?> = rememberSaveable {
@ -116,6 +119,7 @@ class MessagesPresenter @Inject constructor(
composerState = composerState,
timelineState = timelineState,
actionListState = actionListState,
retrySendMenuState = retryState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@ -32,6 +33,7 @@ data class MessagesState(
val composerState: MessageComposerState,
val timelineState: TimelineState,
val actionListState: ActionListState,
val retrySendMenuState: RetrySendMenuState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MessagesEvents) -> Unit

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -48,6 +49,10 @@ fun aMessagesState() = MessagesState(
timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
retrySendMenuState = RetrySendMenuState(
selectedEvent = null,
eventSink = {},
),
actionListState = anActionListState(),
hasNetworkConnection = true,
snackbarMessage = null,

View file

@ -34,14 +34,10 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -59,7 +55,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
@ -67,6 +62,8 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsStat
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
@ -85,6 +82,7 @@ import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@ -176,6 +174,11 @@ fun MessagesView(
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onTimestampClicked = { event ->
if (event.sendState is EventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
}
}
)
},
snackbarHost = {
@ -225,6 +228,10 @@ fun MessagesView(
}
}
)
RetrySendMessageMenu(
state = state.retrySendMenuState
)
}
@Composable
@ -244,10 +251,11 @@ private fun AttachmentStateView(
@Composable
fun MessagesViewContent(
state: MessagesState,
onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onUserDataClicked: (UserId) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
Column(
modifier = modifier
@ -263,6 +271,7 @@ fun MessagesViewContent(
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onTimestampClicked = onTimestampClicked,
)
}
MessageComposerView(

View file

@ -94,6 +94,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: String? = null,
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@ -104,6 +105,7 @@ internal fun aTimelineItemEvent(
return TimelineItem.Event(
id = eventId.value,
eventId = eventId,
transactionId = transactionId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender"),
content = content,

View file

@ -68,10 +68,11 @@ import kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
onUserDataClicked: (UserId) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
onUserDataClicked: (UserId) -> Unit = {},
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) {
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
@ -102,6 +103,7 @@ fun TimelineView(
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
onTimestampClicked = onTimestampClicked,
)
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
@ -125,6 +127,7 @@ fun TimelineItemRow(
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
@ -159,6 +162,7 @@ fun TimelineItemRow(
onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onTimestampClicked = onTimestampClicked,
modifier = modifier,
)
}
@ -191,6 +195,7 @@ fun TimelineItemRow(
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
)
}
}
@ -276,6 +281,10 @@ fun TimelineViewDarkPreview(
private fun ContentToPreview(content: TimelineItemEventContent) {
val timelineItems = aTimelineItemList(content)
TimelineView(
state = aTimelineState(timelineItems)
state = aTimelineState(timelineItems),
onMessageClicked = {},
onTimestampClicked = {},
onUserDataClicked = {},
onMessageLongClicked = {},
)
}

View file

@ -17,12 +17,15 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -54,7 +57,15 @@ fun TimelineEventTimestampView(
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
Row(
modifier = modifier.clickable(onClick = onClick),
modifier = Modifier
.clickable(
onClick = onClick,
enabled = true,
indication = rememberRipple(bounded = false),
interactionSource = MutableInteractionSource()
)
.padding(start = 16.dp) // Add extra padding for touch target size
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {

View file

@ -75,6 +75,7 @@ fun TimelineItemEventRow(
onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
@ -83,7 +84,7 @@ fun TimelineItemEventRow(
onUserDataClick(event.senderId)
}
fun inReplayToClicked() {
fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
inReplyToClick(inReplyToEventId)
}
@ -131,7 +132,10 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = ::inReplayToClicked,
inReplyToClick = ::inReplyToClicked,
onTimestampClicked = {
onTimestampClicked(event)
}
)
}
TimelineItemReactionsView(
@ -177,6 +181,7 @@ private fun MessageEventBubbleContent(
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
modifier: Modifier = Modifier
) {
val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
@ -207,7 +212,7 @@ private fun MessageEventBubbleContent(
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
onClick = onTimestampClicked,
modifier = timestampModifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
@ -220,7 +225,7 @@ private fun MessageEventBubbleContent(
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
onClick = onTimestampClicked,
modifier = timestampModifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 2.dp)

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
object RetrySend : RetrySendMenuEvents
object RemoveFailed : RetrySendMenuEvents
object Dismiss: RetrySendMenuEvents
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.launch
import javax.inject.Inject
class RetrySendMenuPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<RetrySendMenuState> {
@Composable
override fun present(): RetrySendMenuState {
val coroutineScope = rememberCoroutineScope()
var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) }
fun handleEvent(event: RetrySendMenuEvents) {
when (event) {
is RetrySendMenuEvents.EventSelected -> {
selectedEvent = event.event
}
RetrySendMenuEvents.RetrySend -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId)
}
selectedEvent = null
}
}
RetrySendMenuEvents.RemoveFailed -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId)
}
selectedEvent = null
}
}
RetrySendMenuEvents.Dismiss -> {
selectedEvent = null
}
}
}
return RetrySendMenuState(
selectedEvent = selectedEvent,
eventSink = ::handleEvent,
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@Immutable
data class RetrySendMenuState(
val selectedEvent: TimelineItem.Event?,
val eventSink: (RetrySendMenuEvents) -> Unit,
)

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
class RetrySendMenuStateProvider : PreviewParameterProvider<RetrySendMenuState> {
override val values: Sequence<RetrySendMenuState> = sequenceOf(
aRetrySendMenuState(event = null),
aRetrySendMenuState(event = aTimelineItemEvent()),
)
}
fun aRetrySendMenuState(event: TimelineItem.Event? = aTimelineItemEvent()) =
RetrySendMenuState(selectedEvent = event, eventSink = {})

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.features.messages.impl.R
import kotlinx.coroutines.launch
@Composable
internal fun RetrySendMessageMenu(
state: RetrySendMenuState,
modifier: Modifier = Modifier,
) {
val isVisible = state.selectedEvent != null
fun onDismiss() {
state.eventSink(RetrySendMenuEvents.Dismiss)
}
fun onRetry() {
state.eventSink(RetrySendMenuEvents.RetrySend)
}
fun onRemoveFailed() {
state.eventSink(RetrySendMenuEvents.RemoveFailed)
}
RetrySendMessageMenuBottomSheet(
modifier = modifier,
isVisible = isVisible,
onRetry = ::onRetry,
onRemoveFailed = ::onRemoveFailed,
onDismiss = ::onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean,
onRetry: () -> Unit,
onRemoveFailed: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
sheetState = sheetState,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
onDismiss()
}
}
) {
RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed)
// FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ColumnScope.RetrySendMenuContents(
onRetry: () -> Unit,
onRemoveFailed: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
val coroutineScope = rememberCoroutineScope()
ListItem(headlineContent = {
Text(stringResource(R.string.screen_room_retry_send_menu_title), fontWeight = FontWeight.Medium)
})
ListItem(
headlineContent = {
Text(stringResource(R.string.screen_room_retry_send_menu_send_again_action))
},
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRetry()
}
}
)
ListItem(
headlineContent = {
Text(stringResource(R.string.screen_room_retry_send_menu_remove_action))
},
colors = ListItemDefaults.colors(headlineColor = LocalColors.current.textActionCritical),
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRemoveFailed()
}
}
)
}
@Preview
@Composable
internal fun RetrySendMessageMenuPreviewLight(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) {
ElementPreviewLight {
ContentToPreview(state)
}
}
@Preview
@Composable
internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) {
ElementPreviewDark {
ContentToPreview(state)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContentToPreview(state: RetrySendMenuState) {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
Column {
RetrySendMenuContents(
onRetry = {},
onRemoveFailed = {},
)
}
}

View file

@ -72,6 +72,7 @@ class TimelineItemEventFactory @Inject constructor(
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
transactionId = currentTimelineItem.transactionId,
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,

View file

@ -52,6 +52,7 @@ sealed interface TimelineItem {
data class Event(
val id: String,
val eventId: EventId? = null,
val transactionId: String? = null,
val senderId: UserId,
val senderDisplayName: String?,
val senderAvatar: AvatarData,

View file

@ -10,5 +10,8 @@
<string name="screen_room_attachment_source_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_retry_send_menu_remove_action">"Remove"</string>
</resources>

View file

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@ -322,15 +323,18 @@ class MessagesPresenterTest {
flavorShortDescription = "",
)
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter,
retrySendMenuPresenter = retrySendMenuPresenter,
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
dispatchers = testCoroutineDispatchers(),
)
}
}

View file

@ -0,0 +1,161 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components.retrysendmenu
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RetrySendMenuPresenterTests {
private val room = FakeMatrixRoom()
private val presenter = RetrySendMenuPresenter(room)
@Test
fun `present - handle event selected`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
}
}
@Test
fun `present - handle dismiss`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Dismiss)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend with transactionId`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RetrySend)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend without transactionId`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RetrySend)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend with error`() = runTest {
room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RetrySend)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message with transactionId`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message without transactionId`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message with error`() = runTest {
room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
}

View file

@ -83,6 +83,10 @@ interface MatrixRoom : Closeable {
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
suspend fun retrySendMessage(transactionId: String): Result<Unit>
suspend fun cancelSend(transactionId: String): Result<Unit>
suspend fun leave(): Result<Unit>
suspend fun acceptInvitation(): Result<Unit>

View file

@ -24,6 +24,7 @@ sealed interface MatrixTimelineItem {
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
val uniqueId: String = event.uniqueIdentifier
val eventId: EventId? = event.eventId
val transactionId: String? = event.transactionId
}
data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
data class EventTimelineItem(
val uniqueIdentifier: String,
val eventId: EventId?,
val transactionId: String?,
val isEditable: Boolean,
val isLocal: Boolean,
val isOwn: Boolean,

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -264,6 +265,20 @@ class RustMatrixRoom(
}
}
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.retrySend(transactionId)
}
}
override suspend fun cancelSend(transactionId: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.cancelSend(transactionId)
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
withContext(coroutineDispatchers.io) {

View file

@ -35,6 +35,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
EventTimelineItem(
uniqueIdentifier = it.uniqueIdentifier(),
eventId = it.eventId()?.let(::EventId),
transactionId = it.transactionId(),
isEditable = it.isEditable(),
isLocal = it.isLocal(),
isOwn = it.isOwn(),

View file

@ -37,6 +37,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
val A_THREAD_ID = ThreadId("\$aThreadId")
val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2")
const val A_TRANSACTION_ID = "aTransactionId"
const val A_UNIQUE_ID = "aUniqueId"
const val A_ROOM_NAME = "A room name"

View file

@ -71,6 +71,8 @@ class FakeMatrixRoom(
private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = Result.success(Unit)
private var sendReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit)
var sendMediaCount = 0
private set
@ -78,6 +80,12 @@ class FakeMatrixRoom(
var sendReactionCount = 0
private set
var retrySendMessageCount: Int = 0
private set
var cancelSendCount: Int = 0
private set
var isInviteAccepted: Boolean = false
private set
@ -133,6 +141,16 @@ class FakeMatrixRoom(
return sendReactionResult
}
override suspend fun retrySendMessage(transactionId: String): Result<Unit> {
retrySendMessageCount++
return retrySendMessageResult
}
override suspend fun cancelSend(transactionId: String): Result<Unit> {
cancelSendCount++
return cancelSendResult
}
var editMessageParameter: String? = null
private set
@ -292,4 +310,12 @@ class FakeMatrixRoom(
fun givenSendReactionResult(result: Result<Unit>) {
sendReactionResult = result
}
fun givenRetrySendMessageResult(result: Result<Unit>) {
retrySendMessageResult = result
}
fun givenCancelSendResult(result: Result<Unit>) {
cancelSendResult = result
}
}

View file

@ -89,6 +89,7 @@ fun aRoomMessage(
fun anEventTimelineItem(
uniqueIdentifier: String = A_UNIQUE_ID,
eventId: EventId = AN_EVENT_ID,
transactionId: String? = null,
isEditable: Boolean = false,
isLocal: Boolean = false,
isOwn: Boolean = false,
@ -103,6 +104,7 @@ fun anEventTimelineItem(
) = EventTimelineItem(
uniqueIdentifier = uniqueIdentifier,
eventId = eventId,
transactionId = transactionId,
isEditable = isEditable,
isLocal = isLocal,
isOwn = isOwn,

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:307bca06295570124970ed808f4a068f50086212623fa0e69630f52cf9e8752a
size 15440

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:307bca06295570124970ed808f4a068f50086212623fa0e69630f52cf9e8752a
size 15440

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e47e880d5bf8ffedd0662ec0c791483a2cb76e921d12958afffa959b1370c270
size 14073

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e47e880d5bf8ffedd0662ec0c791483a2cb76e921d12958afffa959b1370c270
size 14073

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdf7fe891aa40f4d626733deb130481d64b3315531712128d1b4073e5ccedf19
size 5394
oid sha256:0f56dcf7dfb9ca58618e891aae28099a82457b889da3cd8ec613aeb3757e9750
size 5403

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0baf33f0ec99cd8e4b201d72b38208a02b0511da544276a38d17ff5653e2b754
size 5906
oid sha256:b0c0973dd677105248cd17351ac32e9cf79dc6082eb3a9e26a7ff37e2475ca2f
size 5860

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8d9dc01ec07f9e43032e3ec7610eda5588145f3003cc3d329ddf4325e19f239
size 6674
oid sha256:2fd42d7ae900d5ece9499b1877766d82d8d174d4b8b04633c93e1de04d3aa069
size 6715

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e0f86449d651c50966fe594a46e3a59ae9a5505013e3e36f2458be35ba1844e
size 7174
oid sha256:e324ac2b0578de021d27cddbba851d5835086f80066cb53c1c1829241ac63fec
size 7172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:621facc4799c0069fe83b4be9a5085ecafb042de1cdfbbf4ccf0c15548373aa9
size 5332
oid sha256:fc5e3c5e68dcab10bd67878cd28d1cf769b19cb4daa3dfc02a310b9e44e38d59
size 5362

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:986f40750d69639e771c9bf1aac30c49dbf122aa60dc0280164a8868671b638a
size 5910
oid sha256:8ec5e0cbd36f80526f2a55637d35c41eec7cd8dd07562bc9ed115b2886963b39
size 5888

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d4fb8d68603309963d434f3166821055d818836b1ebfb4fc4c637aba992277d
size 6617
oid sha256:961c557c4d7f77a6d1deb9bbb908cdd855f5e3ebee91e211c91afe857ba3785d
size 6593

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1fa641ca8d5c796c858b54ad997052aabf817bc86e254d38d4b33038bc21eedf
size 7285
oid sha256:833ccceceff1f0a74db95058f3714da514525d05cd3e040619fb221e32ba8d33
size 7287

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:052a95ccf790c0ef93bbfd24ff063fd5489b2c250a9c53ab6d58438653fbbea5
size 46098
oid sha256:259022501a602e5e6e22a800f866d221500f34f7ce475df9996b897f93e7fc49
size 45929

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:744f2192c85b1d6a56cc2eb7f6c873eb45827d2ad2d36fd1678c4ace4118af2c
size 45945
oid sha256:4fe9d2349725bb5c6dbfcfd29dcf8c7c10079deefdc8a0cb367dfe23cb453b85
size 45873