Merge pull request #3011 from element-hq/feature/fga/message_queuing

Feature : First iteration of message queuing
This commit is contained in:
ganfra 2024-06-12 11:38:27 +02:00 committed by GitHub
commit 9776520d7d
37 changed files with 282 additions and 750 deletions

View file

@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.SendingQueue
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
@ -102,6 +103,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint, private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendingQueue,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -157,6 +159,11 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
) )
observeSyncStateAndNetworkStatus() observeSyncStateAndNetworkStatus()
setupSendingQueue()
}
private fun setupSendingQueue() {
sendingQueue.launchIn(lifecycleScope)
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 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.appnav.loggedin
import androidx.annotation.VisibleForTesting
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
private const val SENDING_QUEUE_MIN_RETRY_DELAY = 250L
@VisibleForTesting
const val SENDING_QUEUE_MAX_RETRY_DELAY = 5000L
@SingleIn(SessionScope::class)
class SendingQueue @Inject constructor(
private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor,
) {
private val retryCount = AtomicInteger(0)
fun launchIn(coroutineScope: CoroutineScope) {
combine(
networkMonitor.connectivity,
matrixClient.sendingQueueStatus(),
) { networkStatus, isSendingQueueEnabled ->
Pair(networkStatus, isSendingQueueEnabled)
}.onEach { (networkStatus, isSendingQueueEnabled) ->
Timber.d("Network status: $networkStatus, isSendingQueueEnabled: $isSendingQueueEnabled")
if (networkStatus == NetworkStatus.Online && !isSendingQueueEnabled) {
val retryDelay =
(SENDING_QUEUE_MIN_RETRY_DELAY * retryCount.incrementAndGet()).coerceIn(SENDING_QUEUE_MIN_RETRY_DELAY, SENDING_QUEUE_MAX_RETRY_DELAY)
Timber.d("Retry enabling sending queue in $retryDelay ms")
delay(retryDelay)
} else {
retryCount.set(0)
}
matrixClient.setSendingQueueEnabled(enabled = networkStatus == NetworkStatus.Online)
}.launchIn(coroutineScope)
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 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.appnav.loggedin
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) class SendingQueueTest {
private val matrixClient = FakeMatrixClient()
private val networkMonitor = FakeNetworkMonitor()
private val sut = SendingQueue(matrixClient, networkMonitor)
@Test
fun `test network status online and sending queue is disabled`() = runTest {
val sendingQueueStatusFlow = MutableStateFlow(false)
val setEnableSendingQueueLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendingQueueStatusFlow = sendingQueueStatusFlow
matrixClient.setSendingQueueEnabledLambda = setEnableSendingQueueLambda
sut.launchIn(backgroundScope)
advanceTimeBy(SENDING_QUEUE_MAX_RETRY_DELAY)
sendingQueueStatusFlow.value = true
advanceTimeBy(SENDING_QUEUE_MAX_RETRY_DELAY)
assert(setEnableSendingQueueLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(true)),
listOf(value(true))
)
}
@Test
fun `test network status getting offline and online`() = runTest {
val sendingQueueStatusFlow = MutableStateFlow(true)
val setEnableSendingQueueLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendingQueueStatusFlow = sendingQueueStatusFlow
matrixClient.setSendingQueueEnabledLambda = setEnableSendingQueueLambda
sut.launchIn(backgroundScope)
advanceTimeBy(SENDING_QUEUE_MAX_RETRY_DELAY)
networkMonitor.connectivity.value = NetworkStatus.Offline
advanceTimeBy(SENDING_QUEUE_MAX_RETRY_DELAY)
networkMonitor.connectivity.value = NetworkStatus.Online
advanceTimeBy(SENDING_QUEUE_MAX_RETRY_DELAY)
assert(setEnableSendingQueueLambda)
.isCalledExactly(3)
.withSequence(
listOf(value(true)),
listOf(value(false)),
listOf(value(true)),
)
}
}

View file

@ -46,7 +46,6 @@ import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
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.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@ -107,7 +106,6 @@ class MessagesPresenter @AssistedInject constructor(
private val actionListPresenter: ActionListPresenter, private val actionListPresenter: ActionListPresenter,
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter, private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
@ -140,7 +138,6 @@ class MessagesPresenter @AssistedInject constructor(
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val retryState = retrySendMenuPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@ -231,7 +228,6 @@ class MessagesPresenter @AssistedInject constructor(
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,
retrySendMenuState = retryState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
@ -309,11 +305,9 @@ class MessagesPresenter @AssistedInject constructor(
} }
private suspend fun handleActionRedact(event: TimelineItem.Event) { private suspend fun handleActionRedact(event: TimelineItem.Event) {
if (event.failedToSend) { timelineController.invokeOnCurrentTimeline {
// If the message hasn't been sent yet, just cancel it redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null)
event.transactionId?.let { room.cancelSend(it) } .onFailure { Timber.e(it) }
} else if (event.eventId != null) {
room.redactEvent(event.eventId)
} }
} }

View file

@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -47,7 +46,6 @@ data class MessagesState(
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,
val retrySendMenuState: RetrySendMenuState,
val readReceiptBottomSheetState: ReadReceiptBottomSheetState, val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

View file

@ -31,8 +31,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.aRetrySendMenuState
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.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
@ -110,7 +108,6 @@ fun aMessagesState(
// Render a focused event for an event with sender information displayed // Render a focused event for an event with sender information displayed
focusedEventIndex = 2, focusedEventIndex = 2,
), ),
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(), customReactionState: CustomReactionState = aCustomReactionState(),
@ -132,7 +129,6 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
timelineState = timelineState, timelineState = timelineState,
retrySendMenuState = retrySendMenuState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,

View file

@ -74,8 +74,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
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.messages.impl.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@ -103,7 +101,6 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
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.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
@ -212,11 +209,6 @@ fun MessagesView(
onMessageLongClick = ::onMessageLongClick, onMessageLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = { event ->
if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
}
},
onReactionClick = ::onEmojiReactionClick, onReactionClick = ::onEmojiReactionClick,
onReactionLongClick = ::onEmojiReactionLongClick, onReactionLongClick = ::onEmojiReactionLongClick,
onMoreReactionsClick = ::onMoreReactionsClick, onMoreReactionsClick = ::onMoreReactionsClick,
@ -258,7 +250,6 @@ fun MessagesView(
) )
ReactionSummaryView(state = state.reactionSummaryState) ReactionSummaryView(state = state.reactionSummaryState)
RetrySendMessageMenu(state = state.retrySendMenuState)
ReadReceiptBottomSheet( ReadReceiptBottomSheet(
state = state.readReceiptBottomSheetState, state = state.readReceiptBottomSheetState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
@ -319,7 +310,6 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
@ -387,7 +377,6 @@ private fun MessagesViewContent(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onTimestampClick = onTimestampClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,

View file

@ -181,7 +181,7 @@ class ActionListPresenter @Inject constructor(
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
} }
if (timelineItem.isMine && timelineItem.isTextMessage) { if (timelineItem.isEditable) {
add(TimelineItemAction.Edit) add(TimelineItemAction.Edit)
} }
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {

View file

@ -83,7 +83,6 @@ fun TimelineView(
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit, onMessageClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
@ -148,7 +147,6 @@ fun TimelineView(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onTimestampClick = onTimestampClick,
eventSink = state.eventSink, eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
@ -245,8 +243,8 @@ private fun BoxScope.TimelineScrollHelper(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() }, onClick = { jumpToBottom() },
) )
} }
@ -273,8 +271,8 @@ private fun JumpToBottomButton(
) { ) {
Icon( Icon(
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
.rotate(90f), .rotate(90f),
imageVector = CompoundIcons.ArrowRight(), imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
) )
@ -301,7 +299,6 @@ internal fun TimelineViewPreview(
onLinkClick = {}, onLinkClick = {},
onMessageClick = {}, onMessageClick = {},
onMessageLongClick = {}, onMessageLongClick = {},
onTimestampClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -45,6 +45,5 @@ internal fun ATimelineItemEventRow(
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClick = {},
eventSink = {}, eventSink = {},
) )

View file

@ -16,61 +16,34 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
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.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.isEdited import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun TimelineEventTimestampView( fun TimelineEventTimestampView(
event: TimelineItem.Event, formattedTime: String,
onClick: () -> Unit, isMessageEdited: Boolean,
onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = event.content.isEdited()
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
val clickModifier = if (hasMessageSendingFailed) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(bounded = false),
interactionSource = remember { MutableInteractionSource() }
)
} else {
Modifier
}
Row( Row(
modifier = Modifier modifier = Modifier
.then(clickModifier)
// Add extra padding for touch target size
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier), .then(modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -79,35 +52,22 @@ fun TimelineEventTimestampView(
Text( Text(
stringResource(CommonStrings.common_edited_suffix), stringResource(CommonStrings.common_edited_suffix),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = tint ?: MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
Text( Text(
formattedTime, formattedTime,
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = tint ?: MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
if (hasMessageSendingFailed && tint != null) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier.size(15.dp, 18.dp),
)
}
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview { internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView( TimelineEventTimestampView(formattedTime = event.sentTime, isMessageEdited = event.content.isEdited())
event = event,
onClick = {},
onLongClick = {},
)
} }
object TimelineEventTimestampViewDefaults { object TimelineEventTimestampViewDefaults {

View file

@ -92,6 +92,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
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.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.features.messages.impl.timeline.model.eventId import io.element.android.features.messages.impl.timeline.model.eventId
import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
@ -130,7 +131,6 @@ fun TimelineItemEventRow(
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
@ -190,7 +190,6 @@ fun TimelineItemEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onTimestampClick = onTimestampClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionClick = { emoji -> onReactionClick(emoji, event) },
@ -209,7 +208,6 @@ fun TimelineItemEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onTimestampClick = onTimestampClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionClick = { emoji -> onReactionClick(emoji, event) },
@ -265,7 +263,6 @@ private fun TimelineItemEventRowContent(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit, onUserDataClick: () -> Unit,
onReactionClick: (emoji: String) -> Unit, onReactionClick: (emoji: String) -> Unit,
@ -337,9 +334,6 @@ private fun TimelineItemEventRowContent(
event = event, event = event,
onMessageLongClick = onLongClick, onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onTimestampClick = {
onTimestampClick(event)
},
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = eventSink, eventSink = eventSink,
) )
@ -419,7 +413,6 @@ private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter") @SuppressLint("ModifierParameter")
@ -467,9 +460,8 @@ private fun MessageEventBubbleContent(
Box(modifier, contentAlignment = Alignment.Center) { Box(modifier, contentAlignment = Alignment.Center) {
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, formattedTime = event.sentTime,
onClick = onTimestampClick, isMessageEdited = event.content.isEdited(),
onLongClick = ::onTimestampLongClick,
modifier = Modifier modifier = Modifier
// Outer padding // Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = 4.dp, vertical = 4.dp)
@ -489,9 +481,8 @@ private fun MessageEventBubbleContent(
content = { content(this::onContentLayoutChange) }, content = { content(this::onContentLayoutChange) },
overlay = { overlay = {
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, formattedTime = event.sentTime,
onClick = onTimestampClick, isMessageEdited = event.content.isEdited(),
onLongClick = ::onTimestampLongClick,
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
@ -501,9 +492,8 @@ private fun MessageEventBubbleContent(
Column(modifier) { Column(modifier) {
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, formattedTime = event.sentTime,
onClick = onTimestampClick, isMessageEdited = event.content.isEdited(),
onLongClick = ::onTimestampLongClick,
modifier = Modifier modifier = Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)

View file

@ -49,7 +49,6 @@ fun TimelineItemGroupedEventsRow(
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -76,7 +75,6 @@ fun TimelineItemGroupedEventsRow(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = onTimestampClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
@ -100,7 +98,6 @@ private fun TimelineItemGroupedEventsRowContent(
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -133,7 +130,6 @@ private fun TimelineItemGroupedEventsRowContent(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = onTimestampClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
@ -175,7 +171,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onTimestampClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
@ -200,7 +195,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onTimestampClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},

View file

@ -54,7 +54,6 @@ internal fun TimelineItemRow(
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@ -118,7 +117,6 @@ internal fun TimelineItemRow(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onTimestampClick = onTimestampClick,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink, eventSink = eventSink,
) )
@ -137,7 +135,6 @@ internal fun TimelineItemRow(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = onTimestampClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,

View file

@ -81,7 +81,8 @@ fun TimelineItemReadReceiptView(
} }
} else { } else {
when (state.sendState) { when (state.sendState) {
LocalEventSendState.NotSentYet -> { is LocalEventSendState.SendingFailed,
is LocalEventSendState.NotSentYet -> {
ReadReceiptsRow(modifier) { ReadReceiptsRow(modifier) {
Icon( Icon(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
@ -91,10 +92,6 @@ fun TimelineItemReadReceiptView(
) )
} }
} }
LocalEventSendState.Canceled -> Unit
is LocalEventSendState.SendingFailed -> {
// Error? The timestamp is already displayed in red
}
null, null,
is LocalEventSendState.Sent -> { is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) { if (state.isLastOutgoingMessage) {

View file

@ -123,7 +123,6 @@ private fun ReadReceiptBottomSheetContent(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview { internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
Column { Column {
ReadReceiptBottomSheetContent( ReadReceiptBottomSheetContent(
state = state, state = state,

View file

@ -1,26 +0,0 @@
/*
* 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
data object Retry : RetrySendMenuEvents
data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents
}

View file

@ -1,71 +0,0 @@
/*
* 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.Retry -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId)
}
selectedEvent = null
}
}
RetrySendMenuEvents.Remove -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId)
}
selectedEvent = null
}
}
RetrySendMenuEvents.Dismiss -> {
selectedEvent = null
}
}
}
return RetrySendMenuState(
selectedEvent = selectedEvent,
eventSink = { handleEvent(it) },
)
}
}

View file

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

View file

@ -1,36 +0,0 @@
/*
* 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(),
aRetrySendMenuState(event = aTimelineItemEvent()),
)
}
fun aRetrySendMenuState(
event: TimelineItem.Event? = null,
eventSink: (RetrySendMenuEvents) -> Unit = {},
) = RetrySendMenuState(
selectedEvent = event,
eventSink = eventSink,
)

View file

@ -1,159 +0,0 @@
/*
* 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.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.MaterialTheme
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
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.Retry)
}
fun onRemove() {
state.eventSink(RetrySendMenuEvents.Remove)
}
RetrySendMessageMenuBottomSheet(
modifier = modifier,
isVisible = isVisible,
onRetry = ::onRetry,
onRemove = ::onRemove,
onDismiss = ::onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean,
onRetry: () -> Unit,
onRemove: () -> 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,
onRemove = onRemove,
)
// 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,
onRemove: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
val coroutineScope = rememberCoroutineScope()
ListItem(headlineContent = {
Text(
text = stringResource(R.string.screen_room_retry_send_menu_title),
style = ElementTheme.typography.fontBodyLgMedium,
)
})
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.screen_room_retry_send_menu_send_again_action),
style = ElementTheme.typography.fontBodyLgRegular,
)
},
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRetry()
}
}
)
ListItem(
headlineContent = {
Text(
text = stringResource(CommonStrings.action_remove),
style = ElementTheme.typography.fontBodyLgRegular,
)
},
colors = ListItemDefaults.colors(headlineColor = MaterialTheme.colorScheme.error),
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRemove()
}
}
)
}
@PreviewsDayNight
@Composable
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
RetrySendMessageMenu(
state = state,
)
}

View file

@ -38,7 +38,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
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.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -839,7 +838,6 @@ class MessagesPresenterTest {
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
@ -849,7 +847,6 @@ class MessagesPresenterTest {
actionListPresenter = actionListPresenter, actionListPresenter = actionListPresenter,
customReactionPresenter = customReactionPresenter, customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter, reactionSummaryPresenter = reactionSummaryPresenter,
retrySendMenuPresenter = retrySendMenuPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter, readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),

View file

@ -106,7 +106,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onTimestampClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
@ -123,7 +122,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onTimestampClick = onTimestampClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,

View file

@ -1,163 +0,0 @@
/*
* 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 app.cash.molecule.RecompositionMode
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.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class RetrySendMenuPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val room = FakeMatrixRoom()
private val presenter = RetrySendMenuPresenter(room)
@Test
fun `present - handle event selected`() = runTest {
moleculeFlow(RecompositionMode.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(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Dismiss)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend with transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend without transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
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(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message with transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message without transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message with error`() = runTest {
room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
}

View file

@ -1,90 +0,0 @@
/*
* Copyright (c) 2024 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.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RetrySendMessageMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `dismiss the bottom sheet emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
// Cannot test this for now.
// eventsRecorder.assertSingle(RetrySendMenuEvents.Dismiss)
}
@Config(qualifiers = "h1024dp")
@Test
fun `retry to send the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_room_retry_send_menu_send_again_action)
eventsRecorder.assertSingle(RetrySendMenuEvents.Retry)
}
@Config(qualifiers = "h1024dp")
@Test
fun `remove the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_remove)
eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRetrySendMessageMenu(
state: RetrySendMenuState,
) {
setContent {
RetrySendMessageMenu(
state = state,
)
}
}

View file

@ -68,8 +68,12 @@ class PollRepository @Inject constructor(
suspend fun deletePoll( suspend fun deletePoll(
pollStartId: EventId, pollStartId: EventId,
): Result<Unit> = ): Result<Boolean> =
room.redactEvent( timelineProvider
eventId = pollStartId, .getActiveTimeline()
) .redactEvent(
eventId = pollStartId,
transactionId = null,
reason = null,
)
} }

View file

@ -105,4 +105,20 @@ interface MatrixClient : Closeable {
suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>>
suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<ResolvedRoomAlias> suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<ResolvedRoomAlias>
suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List<String>): Result<RoomPreview> suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List<String>): Result<RoomPreview>
/**
* Enables or disables the sending queue, according to the given parameter.
*
* The sending queue automatically disables itself whenever sending an
* event with it failed (e.g. sending an event via the Timeline),
* so it's required to manually re-enable it as soon as
* connectivity is back on the device.
*/
suspend fun setSendingQueueEnabled(enabled: Boolean)
/**
* Returns the current status of the sending queue as a [StateFlow].
* If true, the sending queue is enabled.
*/
fun sendingQueueStatus(): StateFlow<Boolean>
} }

View file

@ -130,8 +130,6 @@ interface MatrixRoom : Closeable {
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun sendImage( suspend fun sendImage(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
@ -160,7 +158,7 @@ interface MatrixRoom : Closeable {
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit>
suspend fun cancelSend(transactionId: TransactionId): Result<Unit> suspend fun cancelSend(transactionId: TransactionId): Result<Boolean>
suspend fun leave(): Result<Unit> suspend fun leave(): Result<Unit>

View file

@ -83,6 +83,8 @@ interface Timeline : AutoCloseable {
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
suspend fun redactEvent(eventId: EventId?, transactionId: TransactionId?, reason: String?): Result<Boolean>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
@ -91,9 +93,7 @@ interface Timeline : AutoCloseable {
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> suspend fun cancelSend(transactionId: TransactionId): Result<Boolean>
suspend fun cancelSend(transactionId: TransactionId): Result<Unit>
/** /**
* Share a location message in the room. * Share a location message in the room.

View file

@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.core.EventId
@Immutable @Immutable
sealed interface LocalEventSendState { sealed interface LocalEventSendState {
data object NotSentYet : LocalEventSendState data object NotSentYet : LocalEventSendState
data object Canceled : LocalEventSendState
data class SendingFailed( data class SendingFailed(
val error: String val error: String

View file

@ -98,6 +98,7 @@ import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.SendingQueueStatusListener
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
@ -551,6 +552,21 @@ class RustMatrixClient(
}.distinctUntilChanged() }.distinctUntilChanged()
} }
override suspend fun setSendingQueueEnabled(enabled: Boolean) = withContext(sessionDispatcher) {
Timber.i("setSendingQueueEnabled($enabled)")
client.enableSendingQueue(enabled)
}
override fun sendingQueueStatus(): StateFlow<Boolean> = mxCallbackFlow {
client.subscribeToSendingQueueStatus(object : SendingQueueStatusListener {
override fun onValue(newValue: Boolean) {
channel.trySend(newValue)
}
})
}
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = true)
private suspend fun File.getCacheSize( private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false, includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) { ): Long = withContext(sessionDispatcher) {

View file

@ -324,12 +324,6 @@ class RustMatrixRoom(
return liveTimeline.sendMessage(body, htmlBody, mentions) return liveTimeline.sendMessage(body, htmlBody, mentions)
} }
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
runCatching {
innerRoom.redact(eventId.value, reason)
}
}
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) { override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.leave() innerRoom.leave()
@ -435,10 +429,10 @@ class RustMatrixRoom(
} }
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> { override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
return liveTimeline.retrySendMessage(transactionId) return Result.failure(UnsupportedOperationException("Not supported"))
} }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> { override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> {
return liveTimeline.cancelSend(transactionId) return liveTimeline.cancelSend(transactionId)
} }

View file

@ -279,12 +279,32 @@ class RustTimeline(
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) { override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content -> messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
runCatching { runCatching<Unit> {
inner.send(content) inner.send(content)
} }
} }
} }
override suspend fun redactEvent(eventId: EventId?, transactionId: TransactionId?, reason: String?): Result<Boolean> = withContext(dispatcher) {
runCatching {
when {
eventId != null -> {
inner.getEventTimelineItemByEventId(eventId.value).use {
inner.redactEvent(item = it, reason = reason)
}
}
transactionId != null -> {
inner.getEventTimelineItemByTransactionId(transactionId.value).use {
inner.redactEvent(item = it, reason = reason)
}
}
else -> {
error("Either eventId or transactionId must be non-null")
}
}
}
}
override suspend fun editMessage( override suspend fun editMessage(
originalEventId: EventId?, originalEventId: EventId?,
transactionId: TransactionId?, transactionId: TransactionId?,
@ -293,21 +313,24 @@ class RustTimeline(
mentions: List<Mention>, mentions: List<Mention>,
): Result<Unit> = ): Result<Unit> =
withContext(dispatcher) { withContext(dispatcher) {
if (originalEventId != null) { runCatching<Unit> {
runCatching { when {
val editedEvent = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(originalEventId.value) originalEventId != null -> {
editedEvent.use { val editedEvent = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(originalEventId.value)
inner.edit( editedEvent.use {
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), inner.edit(
editItem = it, newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
) editItem = it,
)
}
specialModeEventTimelineItem = null
}
transactionId != null -> {
error("Editing local echo is not supported yet.")
}
else -> {
error("Either originalEventId or transactionId must be non null")
} }
specialModeEventTimelineItem = null
}
} else {
runCatching {
transactionId?.let { cancelSend(it) }
inner.send(messageEventContentFromParts(body, htmlBody))
} }
} }
} }
@ -426,17 +449,7 @@ class RustTimeline(
} }
} }
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) { override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> = redactEvent(eventId = null, transactionId = transactionId, reason = null)
runCatching {
inner.retrySend(transactionId.value)
}
}
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) {
runCatching {
inner.cancelSend(transactionId.value)
}
}
override suspend fun sendLocation( override suspend fun sendLocation(
body: String, body: String,

View file

@ -79,7 +79,6 @@ fun RustEventSendState?.map(): LocalEventSendState? {
RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet
is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error) is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error)
is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId)) is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
RustEventSendState.Cancelled -> LocalEventSendState.Canceled
} }
} }

View file

@ -48,6 +48,7 @@ import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryS
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -298,4 +299,13 @@ class FakeMatrixClient(
} }
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId) override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
var setSendingQueueEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
// no-op
}
override suspend fun setSendingQueueEnabled(enabled: Boolean) = setSendingQueueEnabledLambda(enabled)
var sendingQueueStatusFlow = MutableStateFlow(true)
override fun sendingQueueStatus(): StateFlow<Boolean> = sendingQueueStatusFlow
} }

View file

@ -113,7 +113,7 @@ class FakeMatrixRoom(
private var updateUserRoleResult = Result.success(Unit) private var updateUserRoleResult = Result.success(Unit)
private var toggleReactionResult = Result.success(Unit) private var toggleReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit) private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit) private var cancelSendResult = Result.success(true)
private var forwardEventResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit) private var reportContentResult = Result.success(Unit)
private var kickUserResult = Result.success(Unit) private var kickUserResult = Result.success(Unit)
@ -282,7 +282,7 @@ class FakeMatrixRoom(
return retrySendMessageResult return retrySendMessageResult
} }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> { override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> {
cancelSendCount++ cancelSendCount++
return cancelSendResult return cancelSendResult
} }
@ -295,14 +295,6 @@ class FakeMatrixRoom(
return eventPermalinkResult(eventId) return eventPermalinkResult(eventId)
} }
var redactEventEventIdParam: EventId? = null
private set
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
redactEventEventIdParam = eventId
return Result.success(Unit)
}
override suspend fun leave(): Result<Unit> { override suspend fun leave(): Result<Unit> {
return leaveRoomLambda() return leaveRoomLambda()
} }
@ -631,7 +623,7 @@ class FakeMatrixRoom(
retrySendMessageResult = result retrySendMessageResult = result
} }
fun givenCancelSendResult(result: Result<Unit>) { fun givenCancelSendResult(result: Result<Boolean>) {
cancelSendResult = result cancelSendResult = result
} }

View file

@ -69,6 +69,16 @@ class FakeTimeline(
mentions: List<Mention>, mentions: List<Mention>,
): Result<Unit> = sendMessageLambda(body, htmlBody, mentions) ): Result<Unit> = sendMessageLambda(body, htmlBody, mentions)
var redactEventLambda: (eventId: EventId?, transactionId: TransactionId?, reason: String?) -> Result<Boolean> = { _, _, _ ->
Result.success(true)
}
override suspend fun redactEvent(
eventId: EventId?,
transactionId: TransactionId?,
reason: String?
): Result<Boolean> = redactEventLambda(eventId, transactionId, reason)
var editMessageLambda: ( var editMessageLambda: (
originalEventId: EventId?, originalEventId: EventId?,
transactionId: TransactionId?, transactionId: TransactionId?,
@ -219,11 +229,7 @@ class FakeTimeline(
var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> Result.success(Unit) } var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> Result.success(Unit) }
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = forwardEventLambda(eventId, roomIds) override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = forwardEventLambda(eventId, roomIds)
var retrySendMessageLambda: (transactionId: TransactionId) -> Result<Unit> = { Result.success(Unit) } override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> = redactEvent(null, transactionId, null)
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = retrySendMessageLambda(transactionId)
var cancelSendLambda: (transactionId: TransactionId) -> Result<Unit> = { Result.success(Unit) }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = cancelSendLambda(transactionId)
var sendLocationLambda: ( var sendLocationLambda: (
body: String, body: String,