mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge develop into the PreviewParam rename
The 174 commits develop gained since this branch was cut touch preview data in three ways, and each is resolved so the flipped konsist rule still holds across the merged tree. Two screens the branch had renamed were deleted upstream by "Remove the account provider search screen and homeserver resolver": the changeaccountprovider and searchaccountprovider packages. Their deletion wins, so ChangeAccountProviderStatePreviewParam, SearchAccountProviderStatePreviewParam and the two views that used them go with them rather than coming back under the new name. Two preview classes develop added still carried the old suffix and are renamed here, along with the `@PreviewParameter` reference in each view: GlobalSearchStateProvider -> GlobalSearchStatePreviewParam and SendFailureDialogStateProvider -> SendFailureDialogStatePreviewParam. Everything else merged textually. No production class extending PreviewParameterProvider is left with a `Provider` name, and no reference to any of the 194 renamed names survives outside the CHANGES.md entry that records a past pull request title.
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 {
|
||||
|
||||
+11
@@ -86,6 +86,7 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -103,7 +104,9 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -459,6 +462,14 @@ class MessageComposerPresenter(
|
||||
|
||||
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger)
|
||||
|
||||
// Refresh the room members, which are only loaded on demand, when a mention starts
|
||||
launch {
|
||||
suggestionSearchTrigger
|
||||
.distinctUntilChangedBy { it?.type }
|
||||
.filter { it?.type == SuggestionType.Mention }
|
||||
.collect { room.updateMembers() }
|
||||
}
|
||||
|
||||
val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
|
||||
.getAllRoomAliasSuggestions()
|
||||
.stateIn(this, SharingStarted.Lazily, emptyList())
|
||||
|
||||
+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 SendFailureDialogStatePreviewParam : 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(SendFailureDialogStatePreviewParam::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 = "🙈"
|
||||
|
||||
+54
-2
@@ -1056,7 +1056,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
canTriggerRoomNotification = true,
|
||||
)
|
||||
),
|
||||
updateMembersResult = {},
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
@@ -1103,6 +1104,55 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - mention suggestions refresh the room members`() = runTest {
|
||||
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
|
||||
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
|
||||
val updateMembersResult = lambdaRecorder<Unit> { }
|
||||
val room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
canTriggerRoomNotification = true,
|
||||
),
|
||||
updateMembersResult = updateMembersResult,
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
givenRoomMembersState(
|
||||
RoomMembersState.Ready(
|
||||
persistentListOf(currentUser, bob),
|
||||
)
|
||||
)
|
||||
givenRoomInfo(aRoomInfo(isDirect = false))
|
||||
}
|
||||
val presenter = createPresenter(
|
||||
room = room,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ -> emptyList() },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob))
|
||||
updateMembersResult.assertions().isCalledOnce()
|
||||
|
||||
// Typing after the `@` must not refresh the members again
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 1, SuggestionType.Mention, "b")))
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 2, SuggestionType.Mention, "bo")))
|
||||
advanceUntilIdle()
|
||||
updateMembersResult.assertions().isCalledOnce()
|
||||
|
||||
// Starting a new mention refreshes them again
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(null))
|
||||
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
advanceUntilIdle()
|
||||
updateMembersResult.assertions().isCalledExactly(2)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room mention suggestions no permission`() = runTest {
|
||||
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
|
||||
@@ -1113,7 +1163,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
canTriggerRoomNotification = false,
|
||||
)
|
||||
),
|
||||
updateMembersResult = {},
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
@@ -1144,6 +1195,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
val room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
|
||||
updateMembersResult = {},
|
||||
),
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
).apply {
|
||||
|
||||
+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.RedactedContent
|
||||
@@ -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
@@ -12,8 +12,10 @@ import com.google.common.truth.Truth.assertThat
|
||||
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.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.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
@@ -82,6 +84,50 @@ class TimelineItemsFactoryTest {
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@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