mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge
This commit is contained in:
+14
@@ -400,9 +400,23 @@ class MessagesPresenter(
|
||||
TimelineItemAction.Pin -> handlePinAction(targetEvent)
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> Unit
|
||||
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
|
||||
val sendHandle = targetEvent.sendhandle ?: return Unit.also {
|
||||
Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
|
||||
}
|
||||
sendHandle.retry()
|
||||
.onSuccess {
|
||||
Timber.d("Succeed to add the message back to the send queue")
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to add the message back to the send queue")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
editCaption(
|
||||
|
||||
+36
-4
@@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
|
||||
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
|
||||
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
@@ -178,8 +179,8 @@ fun MessagesView(
|
||||
|
||||
fun onContentClick(event: TimelineItem.Event) {
|
||||
Timber.v("onMessageClick= ${event.id}")
|
||||
val eventId = event.eventId ?: return
|
||||
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
|
||||
val eventId = event.eventId
|
||||
if (eventId != null && eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
|
||||
|
||||
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
|
||||
if (hideKeyboard) {
|
||||
@@ -429,6 +430,31 @@ fun MessagesView(
|
||||
},
|
||||
state = state.linkState,
|
||||
)
|
||||
|
||||
SendFailureDialogView(
|
||||
sendFailureDialogState = state.timelineState.sendFailureDialogState,
|
||||
onDismiss = {
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
onRetry = { event ->
|
||||
state.eventSink(
|
||||
MessagesEvent.HandleAction(
|
||||
action = TimelineItemAction.RetrySending,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
onRemoveMessage = { event ->
|
||||
state.eventSink(
|
||||
MessagesEvent.HandleAction(
|
||||
action = TimelineItemAction.Redact,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||
when {
|
||||
state.successorRoom != null -> {
|
||||
SuccessorRoomBanner(
|
||||
modifier = Modifier.fillMaxWidth().padding(contentPadding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding),
|
||||
roomSuccessor = state.successorRoom,
|
||||
onRoomSuccessorClick = onRoomSuccessorClick
|
||||
)
|
||||
}
|
||||
state.userEventPermissions.canSendMessage -> {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||
if (state.composerState.suggestions.isEmpty() &&
|
||||
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||
|
||||
+3
@@ -36,4 +36,7 @@ enum class TimelineItemAction(
|
||||
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
|
||||
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
|
||||
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
|
||||
|
||||
// Note: this action is not used in UI for now
|
||||
RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
|
||||
}
|
||||
|
||||
+2
@@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
// Not rendered in the action list, but has to be listed here for exhaustiveness.
|
||||
TimelineItemAction.RetrySending,
|
||||
)
|
||||
|
||||
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
|
||||
|
||||
+2
@@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.pinned.list
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
|
||||
sealed interface PinnedMessagesListEvent {
|
||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent
|
||||
data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent
|
||||
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : PinnedMessagesListEvent
|
||||
}
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -50,6 +51,7 @@ class PinnedMessagesListNode(
|
||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
|
||||
@@ -134,6 +136,7 @@ class PinnedMessagesListNode(
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
},
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
+27
-5
@@ -30,6 +30,8 @@ import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
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.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -47,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
@@ -71,6 +74,8 @@ class PinnedMessagesListPresenter(
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
@@ -88,7 +93,7 @@ class PinnedMessagesListPresenter(
|
||||
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
|
||||
config = TimelineItemsFactoryConfig(
|
||||
computeReadReceipts = false,
|
||||
computeReactions = false,
|
||||
computeReactions = true,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -96,6 +101,9 @@ class PinnedMessagesListPresenter(
|
||||
override fun present(): PinnedMessagesListState {
|
||||
htmlConverterProvider.Update()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||
perms.userEventPermissions()
|
||||
}
|
||||
val timelineRoomInfo by remember {
|
||||
derivedStateOf {
|
||||
TimelineRoomInfo(
|
||||
@@ -103,7 +111,7 @@ class PinnedMessagesListPresenter(
|
||||
name = roomInfo.name,
|
||||
// We don't need to compute those values
|
||||
userHasPermissionToSendMessage = false,
|
||||
userHasPermissionToSendReaction = false,
|
||||
userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
|
||||
// We do not care about the call state here.
|
||||
roomCallState = aStandByCallState(),
|
||||
// don't compute this value or the pin icon will be shown
|
||||
@@ -119,9 +127,8 @@ class PinnedMessagesListPresenter(
|
||||
}
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
val linkState = linkPresenter.present()
|
||||
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||
perms.userEventPermissions()
|
||||
}
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
|
||||
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||
|
||||
@@ -138,6 +145,7 @@ class PinnedMessagesListPresenter(
|
||||
when (event) {
|
||||
is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
|
||||
is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId)
|
||||
is PinnedMessagesListEvent.ToggleReaction -> sessionCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,10 +156,20 @@ class PinnedMessagesListPresenter(
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = pinnedMessageItems,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId) {
|
||||
launch {
|
||||
timelineProvider.invokeOnTimeline {
|
||||
toggleReaction(emoji, eventOrTransactionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleTimelineAction(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.Event,
|
||||
@@ -234,6 +252,8 @@ class PinnedMessagesListPresenter(
|
||||
linkState: LinkState,
|
||||
userEventPermissions: UserEventPermissions,
|
||||
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
|
||||
customReactionState: CustomReactionState,
|
||||
reactionSummaryState: ReactionSummaryState,
|
||||
eventSink: (PinnedMessagesListEvent) -> Unit
|
||||
): PinnedMessagesListState {
|
||||
return when (timelineItems) {
|
||||
@@ -252,6 +272,8 @@ class PinnedMessagesListPresenter(
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = timelineItems.data,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
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.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
@@ -34,6 +36,8 @@ sealed interface PinnedMessagesListState {
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val actionListState: ActionListState,
|
||||
val linkState: LinkState,
|
||||
val customReactionState: CustomReactionState,
|
||||
val reactionSummaryState: ReactionSummaryState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val eventSink: (PinnedMessagesListEvent) -> Unit,
|
||||
) : PinnedMessagesListState {
|
||||
|
||||
+8
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.aCustomReactionState
|
||||
import io.element.android.features.messages.impl.aReactionSummaryState
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
@@ -19,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||
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.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
|
||||
@@ -92,6 +96,8 @@ fun aLoadedPinnedMessagesListState(
|
||||
linkState: LinkState = aLinkState(),
|
||||
timelineItems: List<TimelineItem> = emptyList(),
|
||||
actionListState: ActionListState = anActionListState(),
|
||||
customReactionState: CustomReactionState = aCustomReactionState(),
|
||||
reactionSummaryState: ReactionSummaryState = aReactionSummaryState(),
|
||||
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
|
||||
displayThreadSummaries: Boolean = false,
|
||||
eventSink: (PinnedMessagesListEvent) -> Unit = {}
|
||||
@@ -101,6 +107,8 @@ fun aLoadedPinnedMessagesListState(
|
||||
linkState = linkState,
|
||||
timelineItems = timelineItems.toImmutableList(),
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
userEventPermissions = aUserEventPermissions,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
eventSink = eventSink,
|
||||
|
||||
+39
-5
@@ -33,8 +33,12 @@ import io.element.android.features.messages.impl.link.LinkEvent
|
||||
import io.element.android.features.messages.impl.link.LinkView
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
@@ -50,6 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
|
||||
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -66,6 +72,7 @@ fun PinnedMessagesListView(
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -89,6 +96,7 @@ fun PinnedMessagesListView(
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onErrorDismiss = onBackClick,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
@@ -121,6 +129,7 @@ private fun PinnedMessagesListContent(
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
@@ -141,6 +150,7 @@ private fun PinnedMessagesListContent(
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
)
|
||||
PinnedMessagesListState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -180,6 +190,7 @@ private fun PinnedMessagesListLoaded(
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
|
||||
@@ -203,11 +214,25 @@ private fun PinnedMessagesListLoaded(
|
||||
)
|
||||
}
|
||||
|
||||
fun onReactionClick(emoji: String, event: TimelineItem.Event) {
|
||||
state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, event.eventOrTransactionId))
|
||||
}
|
||||
|
||||
fun onReactionLongClick(emoji: String, event: TimelineItem.Event) {
|
||||
event.eventId?.let { eventId ->
|
||||
state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(eventId, event.reactionsState.reactions, emoji))
|
||||
}
|
||||
}
|
||||
|
||||
fun onMoreReactionsClick(event: TimelineItem.Event) {
|
||||
state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
|
||||
}
|
||||
|
||||
ActionListView(
|
||||
state = state.actionListState,
|
||||
onSelectAction = ::onActionSelected,
|
||||
onCustomReactionClick = {},
|
||||
onEmojiReactionClick = { _, _ -> },
|
||||
onCustomReactionClick = ::onMoreReactionsClick,
|
||||
onEmojiReactionClick = ::onReactionClick,
|
||||
onVerifiedUserSendFailureClick = {}
|
||||
)
|
||||
|
||||
@@ -239,9 +264,9 @@ private fun PinnedMessagesListLoaded(
|
||||
onLongClick = ::onMessageLongClick,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReactionClick = ::onReactionClick,
|
||||
onReactionLongClick = ::onReactionLongClick,
|
||||
onMoreReactionsClick = ::onMoreReactionsClick,
|
||||
onReadReceiptClick = {},
|
||||
onSwipeToReply = {},
|
||||
onJoinCallClick = {},
|
||||
@@ -273,6 +298,14 @@ private fun PinnedMessagesListLoaded(
|
||||
state.linkState,
|
||||
onLinkValid = onLinkClick,
|
||||
)
|
||||
CustomReactionBottomSheet(
|
||||
state = state.customReactionState,
|
||||
onSelectEmoji = { eventOrTransactionId, emoji ->
|
||||
state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId))
|
||||
},
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
)
|
||||
ReactionSummaryView(state = state.reactionSummaryState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -322,5 +355,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -34,6 +34,9 @@ sealed interface TimelineEvent {
|
||||
*/
|
||||
sealed interface TimelineItemEvent : TimelineEvent
|
||||
|
||||
data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent
|
||||
data object HideSendFailureDialog : TimelineEvent
|
||||
|
||||
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent
|
||||
data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent
|
||||
|
||||
+63
-3
@@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
|
||||
@@ -142,6 +144,7 @@ class TimelinePresenter(
|
||||
|
||||
val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
|
||||
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
|
||||
var sendFailureDialogState by remember { mutableStateOf<SendFailureDialogState>(SendFailureDialogState.Hidden) }
|
||||
|
||||
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
|
||||
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
|
||||
@@ -166,9 +169,14 @@ class TimelinePresenter(
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
is TimelineEvent.LoadMore -> {
|
||||
if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) {
|
||||
// Do not paginate forwards in thread mode, as it's not supported
|
||||
return
|
||||
if (event.direction == Timeline.PaginationDirection.FORWARDS) {
|
||||
if (timelineMode is Timeline.Mode.Thread) {
|
||||
// Do not paginate forwards in thread mode, as it's not supported
|
||||
return
|
||||
}
|
||||
if (focusRequestState.value.isPending()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
localScope.launch {
|
||||
timelineController.paginate(direction = event.direction)
|
||||
@@ -244,6 +252,47 @@ class TimelinePresenter(
|
||||
suppressJumpToUnread.value = true
|
||||
}
|
||||
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
|
||||
is TimelineEvent.ShowSendFailureDialog -> {
|
||||
val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
|
||||
when (sendStateFailure) {
|
||||
is LocalEventSendState.Failed.VerifiedUser -> {
|
||||
// defer to the resolveVerifiedUserSendFailureState to handle this case,
|
||||
// as it will show a dialog with the right information
|
||||
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
||||
}
|
||||
is LocalEventSendState.Failed.InvalidMimeType -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType),
|
||||
)
|
||||
}
|
||||
LocalEventSendState.Failed.MissingMediaContent -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
)
|
||||
}
|
||||
LocalEventSendState.Failed.SendingFromUnverifiedDevice -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
)
|
||||
}
|
||||
is LocalEventSendState.Failed.Unknown -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = if (sendStateFailure.error.isNotBlank()) {
|
||||
SendFailureDialogState.SendFailureType.Error(sendStateFailure.error)
|
||||
} else {
|
||||
SendFailureDialogState.SendFailureType.Unknown
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TimelineEvent.HideSendFailureDialog -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Hidden
|
||||
}
|
||||
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
|
||||
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
||||
}
|
||||
@@ -408,6 +457,7 @@ class TimelinePresenter(
|
||||
focusRequestState = focusRequestState.value,
|
||||
messageShieldDialogData = messageShieldDialogData.value,
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
@@ -537,6 +587,16 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState {
|
||||
}
|
||||
}
|
||||
|
||||
private fun FocusRequestState.isPending(): Boolean {
|
||||
return when (this) {
|
||||
is FocusRequestState.Requested,
|
||||
is FocusRequestState.Loading -> true
|
||||
is FocusRequestState.Success -> !rendered
|
||||
FocusRequestState.None,
|
||||
is FocusRequestState.Failure -> false
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for not having the server names available, get possible server names from the user ids of the room members
|
||||
private fun calculateServerNamesForRoom(room: JoinedRoom): List<String> {
|
||||
// If we have no room members, return right ahead
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -32,6 +33,7 @@ data class TimelineState(
|
||||
// If not null, info will be rendered in a dialog
|
||||
val messageShieldDialogData: MessageShieldData?,
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val sendFailureDialogState: SendFailureDialogState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayJumpToUnread: Boolean,
|
||||
val jumpToUnread: JumpToUnreadState,
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
@@ -57,6 +58,7 @@ fun aTimelineState(
|
||||
isLive: Boolean = true,
|
||||
messageShield: MessageShield? = null,
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden,
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayJumpToUnread: Boolean = false,
|
||||
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
|
||||
@@ -78,6 +80,7 @@ fun aTimelineState(
|
||||
focusRequestState = focusRequestState,
|
||||
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
|
||||
+4
-4
@@ -56,10 +56,11 @@ fun TimelineEventTimestampView(
|
||||
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
|
||||
val onClickLabel = when {
|
||||
shield != null -> stringResource(CommonStrings.a11y_view_details)
|
||||
hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
|
||||
isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
|
||||
hasError -> stringResource(CommonStrings.a11y_view_details)
|
||||
else -> null
|
||||
}
|
||||
val clickableModifier = remember(shield, hasError) {
|
||||
val clickableModifier = remember(event) {
|
||||
when {
|
||||
shield != null -> {
|
||||
Modifier.clickable(
|
||||
@@ -70,10 +71,9 @@ fun TimelineEventTimestampView(
|
||||
}
|
||||
hasError -> Modifier
|
||||
.clickable(
|
||||
enabled = isVerifiedUserSendFailure,
|
||||
onClickLabel = onClickLabel,
|
||||
) {
|
||||
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
|
||||
eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
}
|
||||
else -> Modifier
|
||||
}
|
||||
|
||||
+13
-1
@@ -21,6 +21,9 @@ import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -110,7 +113,11 @@ class TimelineItemsFactory(
|
||||
): TimelineItem? {
|
||||
val timelineItem =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
|
||||
is MatrixTimelineItem.Event -> if (currentTimelineItem.event.isKeyVerificationRequest()) {
|
||||
null
|
||||
} else {
|
||||
eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
|
||||
}
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
@@ -118,3 +125,8 @@ class TimelineItemsFactory(
|
||||
return timelineItem
|
||||
}
|
||||
}
|
||||
|
||||
private fun EventTimelineItem.isKeyVerificationRequest(): Boolean {
|
||||
val messageType = (content as? MessageContent)?.type
|
||||
return messageType is OtherMessageType && messageType.isKeyVerificationRequest
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
@Immutable
|
||||
sealed interface SendFailureDialogState {
|
||||
data object Hidden : SendFailureDialogState
|
||||
|
||||
data class Show(
|
||||
val event: TimelineItem.Event,
|
||||
val sendFailureType: SendFailureType,
|
||||
) : SendFailureDialogState
|
||||
|
||||
@Immutable
|
||||
sealed interface SendFailureType {
|
||||
data class InvalidMimeType(val mimeType: String) : SendFailureType
|
||||
data object MissingMediaContent : SendFailureType
|
||||
data object SendingFromUnverifiedDevice : SendFailureType
|
||||
data class Error(val message: String) : SendFailureType
|
||||
data object Unknown : SendFailureType
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
|
||||
class SendFailureDialogStateProvider : PreviewParameterProvider<SendFailureDialogState> {
|
||||
override val values: Sequence<SendFailureDialogState>
|
||||
get() = sequenceOf(
|
||||
SendFailureDialogState.Hidden,
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("image/invalid")
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Message error from server"),
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
)
|
||||
)
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun SendFailureDialogView(
|
||||
sendFailureDialogState: SendFailureDialogState,
|
||||
onDismiss: () -> Unit,
|
||||
onRetry: (TimelineItem.Event) -> Unit,
|
||||
onRemoveMessage: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (sendFailureDialogState) {
|
||||
is SendFailureDialogState.Hidden -> Unit
|
||||
is SendFailureDialogState.Show -> {
|
||||
// Show the dialog with the message and actions
|
||||
val content = when (sendFailureDialogState.sendFailureType) {
|
||||
is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource(
|
||||
id = R.string.error_sending_failure_invalid_mime_type,
|
||||
sendFailureDialogState.sendFailureType.mimeType,
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource(
|
||||
id = R.string.error_sending_failure_missing_media_content,
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource(
|
||||
id = R.string.error_sending_failure_sending_from_unverified_device,
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.Error -> stringResource(
|
||||
id = R.string.error_sending_failure_other,
|
||||
sendFailureDialogState.sendFailureType.message
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.Unknown -> stringResource(
|
||||
id = R.string.error_sending_failure_unknown,
|
||||
)
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
modifier = modifier,
|
||||
title = stringResource(id = CommonStrings.common_sending_failed),
|
||||
content = content,
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(id = CommonStrings.action_retry),
|
||||
onSubmitClick = { onRetry(sendFailureDialogState.event) },
|
||||
onCancelClick = onDismiss,
|
||||
thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
|
||||
onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SendFailureDialogViewPreview(
|
||||
@PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState,
|
||||
) = ElementPreview {
|
||||
SendFailureDialogView(
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
onDismiss = {},
|
||||
onRetry = {},
|
||||
onRemoveMessage = {},
|
||||
)
|
||||
}
|
||||
+2
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
@@ -84,6 +85,7 @@ internal fun MessagesViewTopBar(
|
||||
val roundedCornerShape = RoundedCornerShape(8.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.clip(roundedCornerShape)
|
||||
.clickable { onRoomDetailsClick() }
|
||||
.semantics { heading() },
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Encrypted by an unknown or deleted device."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Encrypted by a device not verified by its owner."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Encrypted by an unverified user."</string>
|
||||
<string name="error_sending_failure_invalid_mime_type">"Invalid mime type: %s"</string>
|
||||
<string name="error_sending_failure_missing_media_content">"Missing media content"</string>
|
||||
<string name="error_sending_failure_other">"Error details: %s"</string>
|
||||
<string name="error_sending_failure_sending_from_unverified_device">"Sending from unverified device"</string>
|
||||
<string name="error_sending_failure_unknown">"Unknown error"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
|
||||
|
||||
+52
@@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -83,6 +84,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
@@ -1043,6 +1045,56 @@ class MessagesPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending`() = runTest {
|
||||
val retryLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
retryLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending - failure is ignored`() = runTest {
|
||||
val retryLambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
retryLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest {
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { null },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
// No op!
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action view in timeline, it should have no effect`() = runTest {
|
||||
val messageEvent = aMessageEvent(
|
||||
|
||||
+104
@@ -55,12 +55,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
|
||||
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.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -88,6 +90,7 @@ import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class MessagesViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
|
||||
@@ -176,6 +179,33 @@ class MessagesViewTest : RobolectricTest() {
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on an Event which has not been sent yet invoke expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
||||
val localEcho = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemTextContent(),
|
||||
).copy(eventId = null)
|
||||
val state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
timelineItems = persistentListOf(localEcho),
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
val callback = EnsureCalledOnceWithTwoParamsAndResult(
|
||||
expectedParam1 = true,
|
||||
expectedParam2 = localEcho,
|
||||
result = true,
|
||||
)
|
||||
setMessagesView(
|
||||
state = state,
|
||||
onEventClick = callback,
|
||||
)
|
||||
// Cannot perform click on "Text", it's not detected. Use tag instead
|
||||
onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick()
|
||||
callback.assertSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() {
|
||||
`long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true)
|
||||
@@ -531,6 +561,80 @@ class MessagesViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on retry in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val messagesEventsRecorder = EventsRecorder<MessagesEvent>()
|
||||
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
|
||||
setMessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = timelineItem,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
|
||||
),
|
||||
eventSink = timelineEventsRecorder,
|
||||
),
|
||||
eventSink = messagesEventsRecorder,
|
||||
),
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_retry)
|
||||
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, timelineItem))
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on remove message in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val messagesEventsRecorder = EventsRecorder<MessagesEvent>()
|
||||
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
|
||||
setMessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = timelineItem,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
|
||||
),
|
||||
eventSink = timelineEventsRecorder,
|
||||
),
|
||||
eventSink = messagesEventsRecorder,
|
||||
),
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_remove_message)
|
||||
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem))
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling the send failure dialog emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val messagesEventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
||||
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
|
||||
setMessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = timelineItem,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
|
||||
),
|
||||
eventSink = timelineEventsRecorder,
|
||||
),
|
||||
eventSink = messagesEventsRecorder,
|
||||
),
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_cancel)
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val aUnicode = "🙈"
|
||||
|
||||
+4
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||
import io.element.android.features.messages.impl.aCustomReactionState
|
||||
import io.element.android.features.messages.impl.aReactionSummaryState
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
|
||||
@@ -400,6 +402,8 @@ class PinnedMessagesListPresenterTest {
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
actionListPresenter = { anActionListState() },
|
||||
linkPresenter = { aLinkState() },
|
||||
customReactionPresenter = { aCustomReactionState() },
|
||||
reactionSummaryPresenter = { aReactionSummaryState() },
|
||||
analyticsService = analyticsService,
|
||||
featureFlagService = featureFlagService,
|
||||
sessionCoroutineScope = this,
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
@@ -111,6 +112,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+306
-1
@@ -12,6 +12,8 @@ import app.cash.turbine.ReceiveTurbine
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
|
||||
@@ -21,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -45,6 +48,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
@@ -1059,6 +1063,169 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a forward pagination while the focused event is not rendered yet is ignored`() = runTest {
|
||||
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
timeline = room.liveTimeline,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
|
||||
assertThat(focusedState.isLive).isFalse()
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda).isNeverCalled()
|
||||
assertThat(expectMostRecentItem().isLive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a forward pagination is performed once the focused event has been rendered`() = runTest {
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
timeline = room.liveTimeline,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
|
||||
initialState.eventSink.invoke(TimelineEvent.OnFocusEventRender)
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(Timeline.PaginationDirection.FORWARDS))
|
||||
assertThat(expectMostRecentItem().isLive).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a backward pagination while the focused event is not rendered yet is performed`() = runTest {
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(false)
|
||||
}
|
||||
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
timeline = room.liveTimeline,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(Timeline.PaginationDirection.BACKWARDS))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a forward pagination while the focus request is debounced is ignored`() = runTest {
|
||||
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
timeline = room.liveTimeline,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = 1.seconds))
|
||||
// The focus request has started and is now waiting for the debounce to elapse
|
||||
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Requested }
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda).isNeverCalled()
|
||||
assertThat(expectMostRecentItem().isLive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a forward pagination while the focused timeline is loading is ignored`() = runTest {
|
||||
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
timeline = room.liveTimeline,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
// The focused timeline is being created, the focus request has not resolved yet
|
||||
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Loading }
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda).isNeverCalled()
|
||||
assertThat(expectMostRecentItem().isLive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a forward pagination after a failed focus request is performed`() = runTest {
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(false)
|
||||
}
|
||||
val room = aRoomWithDetachedTimeline(
|
||||
paginateLambda = paginateLambda,
|
||||
createTimelineResult = { Result.failure(RuntimeException("An error")) },
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
room = room,
|
||||
timeline = room.liveTimeline,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Failure }
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(Timeline.PaginationDirection.FORWARDS))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a forward pagination in a thread timeline is ignored`() = runTest {
|
||||
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
|
||||
Result.success(true)
|
||||
}
|
||||
val timeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
val presenter = createTimelinePresenter(timeline = timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda).isNeverCalled()
|
||||
// A backward pagination is still performed in a thread timeline
|
||||
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
advanceUntilIdle()
|
||||
assert(paginateLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(Timeline.PaginationDirection.BACKWARDS))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - focus on known event retrieves the event from cache`() = runTest {
|
||||
val timelineItemIndexer = TimelineItemIndexer()
|
||||
@@ -1390,6 +1557,112 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show and hide send failure dialog`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error"))
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.sendFailureDialogState).isEqualTo(
|
||||
SendFailureDialogState.Show(
|
||||
event = event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"),
|
||||
)
|
||||
)
|
||||
state.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - unknown error with a blank reason`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.Unknown(error = ""),
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - invalid mime type`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"),
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - missing media content`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.MissingMediaContent,
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - sending from unverified device`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice,
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertSendFailureType(
|
||||
sendState: LocalEventSendState.Failed,
|
||||
expectedSendFailureType: SendFailureDialogState.SendFailureType,
|
||||
) = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val event = aMessageEvent(sendState = sendState)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
assertThat(awaitItem().sendFailureDialogState).isEqualTo(
|
||||
SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType)
|
||||
)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest {
|
||||
val resolveEventSinkRecorder = lambdaRecorder<ResolveVerifiedUserSendFailureEvent, Unit> { }
|
||||
val presenter = createTimelinePresenter(
|
||||
resolveVerifiedUserSendFailurePresenter = {
|
||||
aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder)
|
||||
},
|
||||
)
|
||||
val event = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
// The generic dialog is not displayed, the dedicated one is.
|
||||
assertThat(consumeItemsUntilTimeout()).isEmpty()
|
||||
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
resolveEventSinkRecorder.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
assertThat(consumeItemsUntilTimeout()).isEmpty()
|
||||
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
||||
val timeline = FakeTimeline(
|
||||
@@ -1552,6 +1825,37 @@ class TimelinePresenterTest {
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun aRoomWithDetachedTimeline(
|
||||
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
|
||||
createTimelineResult: () -> Result<Timeline> = { Result.success(aDetachedTimeline(paginateLambda)) },
|
||||
): FakeJoinedRoom {
|
||||
return FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())).apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
},
|
||||
createTimelineResult = { createTimelineResult() },
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
threadRootIdForEventResult = { _ -> Result.success(null) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aDetachedTimeline(
|
||||
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
|
||||
) = FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(eventId = AN_EVENT_ID),
|
||||
)
|
||||
)
|
||||
)
|
||||
).apply {
|
||||
this.paginateLambda = paginateLambda
|
||||
}
|
||||
|
||||
private fun roomPermissions(
|
||||
canRedactOther: Boolean = false,
|
||||
canRedactOwn: Boolean = true,
|
||||
@@ -1589,6 +1893,7 @@ class TimelinePresenterTest {
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
|
||||
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -1602,7 +1907,7 @@ class TimelinePresenterTest {
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
timelineController = TimelineController(room, timeline),
|
||||
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
||||
resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
|
||||
typingNotificationPresenter = { aTypingNotificationState() },
|
||||
roomCallStatePresenter = { aStandByCallState() },
|
||||
featureFlagService = featureFlagService,
|
||||
|
||||
+41
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
@@ -178,6 +179,46 @@ class TimelineViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the send failure indicator emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
)
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(timelineItem),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = activity!!.getString(CommonStrings.common_sending_failed)
|
||||
onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the send failure indicator of a verified user failure emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
|
||||
)
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(timelineItem),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = activity!!.getString(CommonStrings.common_sending_failed)
|
||||
onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
|
||||
+46
@@ -13,9 +13,11 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
@@ -122,6 +124,50 @@ class TimelineItemsFactoryTest {
|
||||
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a key verification request is not rendered`() = runTest {
|
||||
val factory = aTimelineItemsFactory(
|
||||
config = TimelineItemsFactoryConfig(
|
||||
computeReadReceipts = false,
|
||||
computeReactions = false,
|
||||
)
|
||||
)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = UniqueId("event-0"),
|
||||
event = anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
content = aMessageContent(
|
||||
body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.",
|
||||
messageType = OtherMessageType(
|
||||
msgType = "m.key.verification.request",
|
||||
body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = UniqueId("event-1"),
|
||||
event = anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
content = aMessageContent(body = "A regular message"),
|
||||
),
|
||||
),
|
||||
)
|
||||
factory.timelineItems.test {
|
||||
factory.replaceWith(
|
||||
timelineItems = items,
|
||||
roomMembers = emptyList(),
|
||||
renderReadReceipts = false,
|
||||
)
|
||||
val bodies = awaitItem()
|
||||
.filterIsInstance<TimelineItem.Event>()
|
||||
.map { (it.content as TimelineItemTextContent).body }
|
||||
assertThat(bodies).containsExactly("A regular message")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> {
|
||||
val factory = aTimelineItemsFactory(
|
||||
config = TimelineItemsFactoryConfig(
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Test
|
||||
|
||||
class SendFailureDialogViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `hidden state renders nothing`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = SendFailureDialogState.Hidden,
|
||||
)
|
||||
onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the error failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("A failure reason"),
|
||||
),
|
||||
)
|
||||
assertNodeWithTextIsDisplayed(CommonStrings.common_sending_failed)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_other, "A failure reason")).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the invalid mime type failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_invalid_mime_type, "invalid/mimeType")).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the missing media content failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_missing_media_content)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the sending from unverified device failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_sending_from_unverified_device)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the unknown failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_unknown)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on retry invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aSendFailureDialogStateShow()
|
||||
ensureCalledOnceWithParam(state.event) { callback ->
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = state,
|
||||
onRetry = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_retry)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on remove message invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aSendFailureDialogStateShow()
|
||||
ensureCalledOnceWithParam(state.event) { callback ->
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = state,
|
||||
onRemoveMessage = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_remove_message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on cancel invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
ensureCalledOnce { callback ->
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(),
|
||||
onDismiss = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_cancel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aSendFailureDialogStateShow(
|
||||
sendFailureType: SendFailureDialogState.SendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
) = SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("")),
|
||||
sendFailureType = sendFailureType,
|
||||
)
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setSendFailureDialogView(
|
||||
sendFailureDialogState: SendFailureDialogState,
|
||||
onDismiss: () -> Unit = EnsureNeverCalled(),
|
||||
onRetry: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onRemoveMessage: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setSafeContent {
|
||||
SendFailureDialogView(
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
onDismiss = onDismiss,
|
||||
onRetry = onRetry,
|
||||
onRemoveMessage = onRemoveMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-26
@@ -60,6 +60,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@@ -67,8 +68,14 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val startRecordResult = lambdaRecorder<Unit> { }
|
||||
private val stopRecordResult = lambdaRecorder<Boolean, Unit> { }
|
||||
private val deleteRecordingResult = lambdaRecorder<Unit> { }
|
||||
private val voiceRecorder = FakeVoiceRecorder(
|
||||
recordingDuration = RECORDING_DURATION
|
||||
recordingDuration = RECORDING_DURATION,
|
||||
startRecordResult = startRecordResult,
|
||||
stopRecordResult = stopRecordResult,
|
||||
deleteRecordingResult = deleteRecordingResult,
|
||||
)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
private val sendVoiceMessageResult =
|
||||
@@ -97,7 +104,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
companion object {
|
||||
private val RECORDING_DURATION = 1.seconds
|
||||
private val FIRST_LEVEL_DURATION = 500.milliseconds
|
||||
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList())
|
||||
private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,7 +115,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
startRecordResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
@@ -118,9 +127,10 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
presenter.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(FIRST_RECORDING_STATE)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -133,13 +143,18 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val voiceRecorder = FakeVoiceRecorder(
|
||||
levels = levels,
|
||||
recordingDuration = RECORDING_DURATION,
|
||||
startRecordResult = { },
|
||||
stopRecordResult = { },
|
||||
deleteRecordingResult = { },
|
||||
)
|
||||
val presenter = createDefaultVoiceMessageComposerPresenter(
|
||||
voiceRecorder = voiceRecorder,
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
skipItems(numberOfLevels / 2 - 1)
|
||||
|
||||
// Skip until we reach the final state, which should have the last 128 levels
|
||||
skipItems(numberOfLevels - 1)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java)
|
||||
val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording
|
||||
@@ -221,7 +236,8 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
@@ -235,7 +251,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(true))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
@@ -249,7 +267,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -265,8 +285,10 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
}
|
||||
|
||||
// Nothing should happen
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(FIRST_RECORDING_STATE)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isNeverCalled()
|
||||
deleteRecordingResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -282,7 +304,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||
}
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -299,7 +323,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
|
||||
}
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -340,7 +366,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -360,7 +388,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -377,7 +407,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -455,7 +487,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -476,7 +510,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -499,7 +535,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
assertThat(analyticsService.trackedErrors).isEmpty()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -527,7 +565,9 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
deleteRecordingResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -577,7 +617,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
assertThat(analyticsService.trackedErrors).hasSize(1)
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
startRecordResult.assertions().isNeverCalled()
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
@@ -586,8 +626,14 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
@Test
|
||||
fun `present - record error - security exceptions are tracked`() = runTest {
|
||||
val exception = SecurityException("")
|
||||
voiceRecorder.givenThrowsSecurityException(exception)
|
||||
val presenter = createDefaultVoiceMessageComposerPresenter()
|
||||
val startRecordResult = lambdaRecorder<Unit> { throw exception }
|
||||
val voiceRecorder = FakeVoiceRecorder(
|
||||
recordingDuration = RECORDING_DURATION,
|
||||
startRecordResult = startRecordResult,
|
||||
)
|
||||
val presenter = createDefaultVoiceMessageComposerPresenter(
|
||||
voiceRecorder = voiceRecorder,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
@@ -596,7 +642,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
assertThat(analyticsService.trackedErrors).containsExactly(
|
||||
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
|
||||
)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
@@ -616,7 +662,8 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
|
||||
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
voiceRecorder.assertCalls(stopped = 1)
|
||||
startRecordResult.assertions().isNeverCalled()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
|
||||
@@ -625,7 +672,8 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = expectMostRecentItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(stopped = 1, started = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
stopRecordResult.assertions().isCalledOnce().with(value(false))
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -659,7 +707,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
|
||||
val finalState = expectMostRecentItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
startRecordResult.assertions().isCalledOnce()
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
@@ -696,7 +744,7 @@ class DefaultVoiceMessageComposerPresenterTest {
|
||||
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||
}
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
startRecordResult.assertions().isNeverCalled()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
testPauseAndDestroy(finalState)
|
||||
|
||||
Reference in New Issue
Block a user