[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:
parent
9f2d6bde0e
commit
7ddf93ed09
41 changed files with 641 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {})
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,5 +10,8 @@
|
|||
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Photo & 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>
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue