[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
1
changelog.d/487.feature
Normal file
1
changelog.d/487.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add menu to retry sending failed messages or delete their local echoes.
|
||||||
|
|
@ -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.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
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.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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
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 composerPresenter: MessageComposerPresenter,
|
||||||
private val timelinePresenter: TimelinePresenter,
|
private val timelinePresenter: TimelinePresenter,
|
||||||
private val actionListPresenter: ActionListPresenter,
|
private val actionListPresenter: ActionListPresenter,
|
||||||
|
private val retrySendMenuPresenter: RetrySendMenuPresenter,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
|
|
@ -77,6 +79,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
val composerState = composerPresenter.present()
|
val composerState = composerPresenter.present()
|
||||||
val timelineState = timelinePresenter.present()
|
val timelineState = timelinePresenter.present()
|
||||||
val actionListState = actionListPresenter.present()
|
val actionListState = actionListPresenter.present()
|
||||||
|
val retryState = retrySendMenuPresenter.present()
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
|
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
|
||||||
val roomName: MutableState<String?> = rememberSaveable {
|
val roomName: MutableState<String?> = rememberSaveable {
|
||||||
|
|
@ -116,6 +119,7 @@ class MessagesPresenter @Inject constructor(
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
|
retrySendMenuState = retryState,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
eventSink = ::handleEvents
|
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.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
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.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.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -32,6 +33,7 @@ data class MessagesState(
|
||||||
val composerState: MessageComposerState,
|
val composerState: MessageComposerState,
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
|
val retrySendMenuState: RetrySendMenuState,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
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.messagecomposer.aMessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
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.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.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -48,6 +49,10 @@ fun aMessagesState() = MessagesState(
|
||||||
timelineState = aTimelineState().copy(
|
timelineState = aTimelineState().copy(
|
||||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
),
|
),
|
||||||
|
retrySendMenuState = RetrySendMenuState(
|
||||||
|
selectedEvent = null,
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
actionListState = anActionListState(),
|
actionListState = anActionListState(),
|
||||||
hasNetworkConnection = true,
|
hasNetworkConnection = true,
|
||||||
snackbarMessage = null,
|
snackbarMessage = null,
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,10 @@ import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.SheetValue
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
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.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.messagecomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
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.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.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.TimelineItemDebugInfo
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -176,6 +174,11 @@ fun MessagesView(
|
||||||
onMessageClicked = ::onMessageClicked,
|
onMessageClicked = ::onMessageClicked,
|
||||||
onMessageLongClicked = ::onMessageLongClicked,
|
onMessageLongClicked = ::onMessageLongClicked,
|
||||||
onUserDataClicked = onUserDataClicked,
|
onUserDataClicked = onUserDataClicked,
|
||||||
|
onTimestampClicked = { event ->
|
||||||
|
if (event.sendState is EventSendState.SendingFailed) {
|
||||||
|
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
|
|
@ -225,6 +228,10 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RetrySendMessageMenu(
|
||||||
|
state = state.retrySendMenuState
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -244,10 +251,11 @@ private fun AttachmentStateView(
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesViewContent(
|
fun MessagesViewContent(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
|
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
|
||||||
onUserDataClicked: (UserId) -> Unit = {},
|
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -263,6 +271,7 @@ fun MessagesViewContent(
|
||||||
onMessageClicked = onMessageClicked,
|
onMessageClicked = onMessageClicked,
|
||||||
onMessageLongClicked = onMessageLongClicked,
|
onMessageLongClicked = onMessageLongClicked,
|
||||||
onUserDataClicked = onUserDataClicked,
|
onUserDataClicked = onUserDataClicked,
|
||||||
|
onTimestampClicked = onTimestampClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
||||||
|
|
||||||
internal fun aTimelineItemEvent(
|
internal fun aTimelineItemEvent(
|
||||||
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
|
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
|
||||||
|
transactionId: String? = null,
|
||||||
isMine: Boolean = false,
|
isMine: Boolean = false,
|
||||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
|
|
@ -104,6 +105,7 @@ internal fun aTimelineItemEvent(
|
||||||
return TimelineItem.Event(
|
return TimelineItem.Event(
|
||||||
id = eventId.value,
|
id = eventId.value,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
|
transactionId = transactionId,
|
||||||
senderId = UserId("@senderId:domain"),
|
senderId = UserId("@senderId:domain"),
|
||||||
senderAvatar = AvatarData("@senderId:domain", "sender"),
|
senderAvatar = AvatarData("@senderId:domain", "sender"),
|
||||||
content = content,
|
content = content,
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,11 @@ import kotlinx.coroutines.launch
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineView(
|
fun TimelineView(
|
||||||
state: TimelineState,
|
state: TimelineState,
|
||||||
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
|
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onUserDataClicked: (UserId) -> Unit = {},
|
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
fun onReachedLoadMore() {
|
fun onReachedLoadMore() {
|
||||||
state.eventSink(TimelineEvents.LoadMore)
|
state.eventSink(TimelineEvents.LoadMore)
|
||||||
|
|
@ -102,6 +103,7 @@ fun TimelineView(
|
||||||
onLongClick = onMessageLongClicked,
|
onLongClick = onMessageLongClicked,
|
||||||
onUserDataClick = onUserDataClicked,
|
onUserDataClick = onUserDataClicked,
|
||||||
inReplyToClick = ::inReplyToClicked,
|
inReplyToClick = ::inReplyToClicked,
|
||||||
|
onTimestampClicked = onTimestampClicked,
|
||||||
)
|
)
|
||||||
if (index == state.timelineItems.lastIndex) {
|
if (index == state.timelineItems.lastIndex) {
|
||||||
onReachedLoadMore()
|
onReachedLoadMore()
|
||||||
|
|
@ -125,6 +127,7 @@ fun TimelineItemRow(
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
when (timelineItem) {
|
when (timelineItem) {
|
||||||
|
|
@ -159,6 +162,7 @@ fun TimelineItemRow(
|
||||||
onLongClick = ::onLongClick,
|
onLongClick = ::onLongClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
|
onTimestampClicked = onTimestampClicked,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +195,7 @@ fun TimelineItemRow(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
|
onTimestampClicked = onTimestampClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,6 +281,10 @@ fun TimelineViewDarkPreview(
|
||||||
private fun ContentToPreview(content: TimelineItemEventContent) {
|
private fun ContentToPreview(content: TimelineItemEventContent) {
|
||||||
val timelineItems = aTimelineItemList(content)
|
val timelineItems = aTimelineItemList(content)
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = aTimelineState(timelineItems)
|
state = aTimelineState(timelineItems),
|
||||||
|
onMessageClicked = {},
|
||||||
|
onTimestampClicked = {},
|
||||||
|
onUserDataClicked = {},
|
||||||
|
onMessageLongClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,15 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -54,7 +57,15 @@ fun TimelineEventTimestampView(
|
||||||
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||||
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
|
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
|
||||||
Row(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (isMessageEdited) {
|
if (isMessageEdited) {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ fun TimelineItemEventRow(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
@ -83,7 +84,7 @@ fun TimelineItemEventRow(
|
||||||
onUserDataClick(event.senderId)
|
onUserDataClick(event.senderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun inReplayToClicked() {
|
fun inReplyToClicked() {
|
||||||
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
||||||
inReplyToClick(inReplyToEventId)
|
inReplyToClick(inReplyToEventId)
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +132,10 @@ fun TimelineItemEventRow(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onMessageClick = onClick,
|
onMessageClick = onClick,
|
||||||
onMessageLongClick = onLongClick,
|
onMessageLongClick = onLongClick,
|
||||||
inReplyToClick = ::inReplayToClicked,
|
inReplyToClick = ::inReplyToClicked,
|
||||||
|
onTimestampClicked = {
|
||||||
|
onTimestampClicked(event)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
|
|
@ -177,6 +181,7 @@ private fun MessageEventBubbleContent(
|
||||||
onMessageClick: () -> Unit,
|
onMessageClick: () -> Unit,
|
||||||
onMessageLongClick: () -> Unit,
|
onMessageLongClick: () -> Unit,
|
||||||
inReplyToClick: () -> Unit,
|
inReplyToClick: () -> Unit,
|
||||||
|
onTimestampClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
|
val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
|
||||||
|
|
@ -207,7 +212,7 @@ private fun MessageEventBubbleContent(
|
||||||
ContentView(modifier = contentModifier)
|
ContentView(modifier = contentModifier)
|
||||||
TimelineEventTimestampView(
|
TimelineEventTimestampView(
|
||||||
event = event,
|
event = event,
|
||||||
onClick = onMessageClick,
|
onClick = onTimestampClicked,
|
||||||
modifier = timestampModifier
|
modifier = timestampModifier
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
|
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
|
||||||
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
|
.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))
|
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
|
||||||
TimelineEventTimestampView(
|
TimelineEventTimestampView(
|
||||||
event = event,
|
event = event,
|
||||||
onClick = onMessageClick,
|
onClick = onTimestampClicked,
|
||||||
modifier = timestampModifier
|
modifier = timestampModifier
|
||||||
.align(Alignment.End)
|
.align(Alignment.End)
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
.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(
|
return TimelineItem.Event(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
eventId = currentTimelineItem.eventId,
|
eventId = currentTimelineItem.eventId,
|
||||||
|
transactionId = currentTimelineItem.transactionId,
|
||||||
senderId = currentSender,
|
senderId = currentSender,
|
||||||
senderDisplayName = senderDisplayName,
|
senderDisplayName = senderDisplayName,
|
||||||
senderAvatar = senderAvatarData,
|
senderAvatar = senderAvatarData,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ sealed interface TimelineItem {
|
||||||
data class Event(
|
data class Event(
|
||||||
val id: String,
|
val id: String,
|
||||||
val eventId: EventId? = null,
|
val eventId: EventId? = null,
|
||||||
|
val transactionId: String? = null,
|
||||||
val senderId: UserId,
|
val senderId: UserId,
|
||||||
val senderDisplayName: String?,
|
val senderDisplayName: String?,
|
||||||
val senderAvatar: AvatarData,
|
val senderAvatar: AvatarData,
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,8 @@
|
||||||
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||||
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</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_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_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>
|
</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.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
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.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.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
|
@ -322,15 +323,18 @@ class MessagesPresenterTest {
|
||||||
flavorShortDescription = "",
|
flavorShortDescription = "",
|
||||||
)
|
)
|
||||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||||
|
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
composerPresenter = messageComposerPresenter,
|
composerPresenter = messageComposerPresenter,
|
||||||
timelinePresenter = timelinePresenter,
|
timelinePresenter = timelinePresenter,
|
||||||
actionListPresenter = actionListPresenter,
|
actionListPresenter = actionListPresenter,
|
||||||
|
retrySendMenuPresenter = retrySendMenuPresenter,
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||||
dispatchers = testCoroutineDispatchers(),
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,10 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun sendReaction(emoji: String, eventId: EventId): Result<Unit>
|
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 leave(): Result<Unit>
|
||||||
|
|
||||||
suspend fun acceptInvitation(): Result<Unit>
|
suspend fun acceptInvitation(): Result<Unit>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ sealed interface MatrixTimelineItem {
|
||||||
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
|
data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
|
||||||
val uniqueId: String = event.uniqueIdentifier
|
val uniqueId: String = event.uniqueIdentifier
|
||||||
val eventId: EventId? = event.eventId
|
val eventId: EventId? = event.eventId
|
||||||
|
val transactionId: String? = event.transactionId
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem
|
data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
|
||||||
data class EventTimelineItem(
|
data class EventTimelineItem(
|
||||||
val uniqueIdentifier: String,
|
val uniqueIdentifier: String,
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
|
val transactionId: String?,
|
||||||
val isEditable: Boolean,
|
val isEditable: Boolean,
|
||||||
val isLocal: Boolean,
|
val isLocal: Boolean,
|
||||||
val isOwn: Boolean,
|
val isOwn: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -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.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
|
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
|
||||||
EventTimelineItem(
|
EventTimelineItem(
|
||||||
uniqueIdentifier = it.uniqueIdentifier(),
|
uniqueIdentifier = it.uniqueIdentifier(),
|
||||||
eventId = it.eventId()?.let(::EventId),
|
eventId = it.eventId()?.let(::EventId),
|
||||||
|
transactionId = it.transactionId(),
|
||||||
isEditable = it.isEditable(),
|
isEditable = it.isEditable(),
|
||||||
isLocal = it.isLocal(),
|
isLocal = it.isLocal(),
|
||||||
isOwn = it.isOwn(),
|
isOwn = it.isOwn(),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
|
||||||
val A_THREAD_ID = ThreadId("\$aThreadId")
|
val A_THREAD_ID = ThreadId("\$aThreadId")
|
||||||
val AN_EVENT_ID = EventId("\$anEventId")
|
val AN_EVENT_ID = EventId("\$anEventId")
|
||||||
val AN_EVENT_ID_2 = EventId("\$anEventId2")
|
val AN_EVENT_ID_2 = EventId("\$anEventId2")
|
||||||
|
const val A_TRANSACTION_ID = "aTransactionId"
|
||||||
const val A_UNIQUE_ID = "aUniqueId"
|
const val A_UNIQUE_ID = "aUniqueId"
|
||||||
|
|
||||||
const val A_ROOM_NAME = "A room name"
|
const val A_ROOM_NAME = "A room name"
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ class FakeMatrixRoom(
|
||||||
private var updateAvatarResult = Result.success(Unit)
|
private var updateAvatarResult = Result.success(Unit)
|
||||||
private var removeAvatarResult = Result.success(Unit)
|
private var removeAvatarResult = Result.success(Unit)
|
||||||
private var sendReactionResult = Result.success(Unit)
|
private var sendReactionResult = Result.success(Unit)
|
||||||
|
private var retrySendMessageResult = Result.success(Unit)
|
||||||
|
private var cancelSendResult = Result.success(Unit)
|
||||||
|
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
@ -78,6 +80,12 @@ class FakeMatrixRoom(
|
||||||
var sendReactionCount = 0
|
var sendReactionCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var retrySendMessageCount: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
var cancelSendCount: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
var isInviteAccepted: Boolean = false
|
var isInviteAccepted: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -133,6 +141,16 @@ class FakeMatrixRoom(
|
||||||
return sendReactionResult
|
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
|
var editMessageParameter: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -292,4 +310,12 @@ class FakeMatrixRoom(
|
||||||
fun givenSendReactionResult(result: Result<Unit>) {
|
fun givenSendReactionResult(result: Result<Unit>) {
|
||||||
sendReactionResult = result
|
sendReactionResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenRetrySendMessageResult(result: Result<Unit>) {
|
||||||
|
retrySendMessageResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenCancelSendResult(result: Result<Unit>) {
|
||||||
|
cancelSendResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ fun aRoomMessage(
|
||||||
fun anEventTimelineItem(
|
fun anEventTimelineItem(
|
||||||
uniqueIdentifier: String = A_UNIQUE_ID,
|
uniqueIdentifier: String = A_UNIQUE_ID,
|
||||||
eventId: EventId = AN_EVENT_ID,
|
eventId: EventId = AN_EVENT_ID,
|
||||||
|
transactionId: String? = null,
|
||||||
isEditable: Boolean = false,
|
isEditable: Boolean = false,
|
||||||
isLocal: Boolean = false,
|
isLocal: Boolean = false,
|
||||||
isOwn: Boolean = false,
|
isOwn: Boolean = false,
|
||||||
|
|
@ -103,6 +104,7 @@ fun anEventTimelineItem(
|
||||||
) = EventTimelineItem(
|
) = EventTimelineItem(
|
||||||
uniqueIdentifier = uniqueIdentifier,
|
uniqueIdentifier = uniqueIdentifier,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
|
transactionId = transactionId,
|
||||||
isEditable = isEditable,
|
isEditable = isEditable,
|
||||||
isLocal = isLocal,
|
isLocal = isLocal,
|
||||||
isOwn = isOwn,
|
isOwn = isOwn,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:307bca06295570124970ed808f4a068f50086212623fa0e69630f52cf9e8752a
|
||||||
|
size 15440
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:307bca06295570124970ed808f4a068f50086212623fa0e69630f52cf9e8752a
|
||||||
|
size 15440
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e47e880d5bf8ffedd0662ec0c791483a2cb76e921d12958afffa959b1370c270
|
||||||
|
size 14073
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e47e880d5bf8ffedd0662ec0c791483a2cb76e921d12958afffa959b1370c270
|
||||||
|
size 14073
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bdf7fe891aa40f4d626733deb130481d64b3315531712128d1b4073e5ccedf19
|
oid sha256:0f56dcf7dfb9ca58618e891aae28099a82457b889da3cd8ec613aeb3757e9750
|
||||||
size 5394
|
size 5403
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0baf33f0ec99cd8e4b201d72b38208a02b0511da544276a38d17ff5653e2b754
|
oid sha256:b0c0973dd677105248cd17351ac32e9cf79dc6082eb3a9e26a7ff37e2475ca2f
|
||||||
size 5906
|
size 5860
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c8d9dc01ec07f9e43032e3ec7610eda5588145f3003cc3d329ddf4325e19f239
|
oid sha256:2fd42d7ae900d5ece9499b1877766d82d8d174d4b8b04633c93e1de04d3aa069
|
||||||
size 6674
|
size 6715
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5e0f86449d651c50966fe594a46e3a59ae9a5505013e3e36f2458be35ba1844e
|
oid sha256:e324ac2b0578de021d27cddbba851d5835086f80066cb53c1c1829241ac63fec
|
||||||
size 7174
|
size 7172
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:621facc4799c0069fe83b4be9a5085ecafb042de1cdfbbf4ccf0c15548373aa9
|
oid sha256:fc5e3c5e68dcab10bd67878cd28d1cf769b19cb4daa3dfc02a310b9e44e38d59
|
||||||
size 5332
|
size 5362
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:986f40750d69639e771c9bf1aac30c49dbf122aa60dc0280164a8868671b638a
|
oid sha256:8ec5e0cbd36f80526f2a55637d35c41eec7cd8dd07562bc9ed115b2886963b39
|
||||||
size 5910
|
size 5888
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6d4fb8d68603309963d434f3166821055d818836b1ebfb4fc4c637aba992277d
|
oid sha256:961c557c4d7f77a6d1deb9bbb908cdd855f5e3ebee91e211c91afe857ba3785d
|
||||||
size 6617
|
size 6593
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1fa641ca8d5c796c858b54ad997052aabf817bc86e254d38d4b33038bc21eedf
|
oid sha256:833ccceceff1f0a74db95058f3714da514525d05cd3e040619fb221e32ba8d33
|
||||||
size 7285
|
size 7287
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:052a95ccf790c0ef93bbfd24ff063fd5489b2c250a9c53ab6d58438653fbbea5
|
oid sha256:259022501a602e5e6e22a800f866d221500f34f7ce475df9996b897f93e7fc49
|
||||||
size 46098
|
size 45929
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:744f2192c85b1d6a56cc2eb7f6c873eb45827d2ad2d36fd1678c4ace4118af2c
|
oid sha256:4fe9d2349725bb5c6dbfcfd29dcf8c7c10079deefdc8a0cb367dfe23cb453b85
|
||||||
size 45945
|
size 45873
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue