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:
hayaksi1
2026-08-24 10:55:40 +03:00
397 changed files with 6635 additions and 4221 deletions
@@ -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(
@@ -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) {
@@ -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),
}
@@ -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 {
@@ -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())
@@ -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
}
@@ -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
)
}
@@ -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
)
}
@@ -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 {
@@ -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,
@@ -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,
)
}
@@ -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
@@ -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
@@ -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,
@@ -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,
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -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,
)
)
}
@@ -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 = {},
)
}
@@ -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>
@@ -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(
@@ -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 = "🙈"
@@ -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 {
@@ -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,
@@ -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,
)
}
}
@@ -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,
@@ -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>()
@@ -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(
@@ -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,
)
}
}
}
@@ -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)