This commit is contained in:
bxdxnn
2026-08-24 10:30:25 +00:00
318 changed files with 5976 additions and 2161 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 {
@@ -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 SendFailureDialogStateProvider : PreviewParameterProvider<SendFailureDialogState> {
override val values: Sequence<SendFailureDialogState>
get() = sequenceOf(
SendFailureDialogState.Hidden,
SendFailureDialogState.Show(
event = aTimelineItemEvent(),
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("image/invalid")
),
SendFailureDialogState.Show(
event = aTimelineItemEvent(),
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
),
SendFailureDialogState.Show(
event = aTimelineItemEvent(),
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
),
SendFailureDialogState.Show(
event = aTimelineItemEvent(),
sendFailureType = SendFailureDialogState.SendFailureType.Error("Message error from server"),
),
SendFailureDialogState.Show(
event = aTimelineItemEvent(),
sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
)
)
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.sendfailure
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SendFailureDialogView(
sendFailureDialogState: SendFailureDialogState,
onDismiss: () -> Unit,
onRetry: (TimelineItem.Event) -> Unit,
onRemoveMessage: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
when (sendFailureDialogState) {
is SendFailureDialogState.Hidden -> Unit
is SendFailureDialogState.Show -> {
// Show the dialog with the message and actions
val content = when (sendFailureDialogState.sendFailureType) {
is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource(
id = R.string.error_sending_failure_invalid_mime_type,
sendFailureDialogState.sendFailureType.mimeType,
)
is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource(
id = R.string.error_sending_failure_missing_media_content,
)
is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource(
id = R.string.error_sending_failure_sending_from_unverified_device,
)
is SendFailureDialogState.SendFailureType.Error -> stringResource(
id = R.string.error_sending_failure_other,
sendFailureDialogState.sendFailureType.message
)
is SendFailureDialogState.SendFailureType.Unknown -> stringResource(
id = R.string.error_sending_failure_unknown,
)
}
ConfirmationDialog(
modifier = modifier,
title = stringResource(id = CommonStrings.common_sending_failed),
content = content,
onDismiss = onDismiss,
submitText = stringResource(id = CommonStrings.action_retry),
onSubmitClick = { onRetry(sendFailureDialogState.event) },
onCancelClick = onDismiss,
thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SendFailureDialogViewPreview(
@PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState,
) = ElementPreview {
SendFailureDialogView(
sendFailureDialogState = sendFailureDialogState,
onDismiss = {},
onRetry = {},
onRemoveMessage = {},
)
}
@@ -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 = "🙈"
@@ -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.TimelineItemEventOrigin
@@ -1059,6 +1063,169 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - a forward pagination while the focused event is not rendered yet is ignored`() = runTest {
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
assertThat(focusedState.isLive).isFalse()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
}
@Test
fun `present - a forward pagination is performed once the focused event has been rendered`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
initialState.eventSink.invoke(TimelineEvent.OnFocusEventRender)
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.FORWARDS))
assertThat(expectMostRecentItem().isLive).isTrue()
}
}
@Test
fun `present - a backward pagination while the focused event is not rendered yet is performed`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(false)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.BACKWARDS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a forward pagination while the focus request is debounced is ignored`() = runTest {
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = 1.seconds))
// The focus request has started and is now waiting for the debounce to elapse
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Requested }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
}
@Test
fun `present - a forward pagination while the focused timeline is loading is ignored`() = runTest {
val paginateLambda = lambdaRecorder(ensureNeverCalled = true) { _: Timeline.PaginationDirection ->
Result.success(true)
}
val room = aRoomWithDetachedTimeline(paginateLambda = paginateLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
// The focused timeline is being created, the focus request has not resolved yet
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Loading }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
}
@Test
fun `present - a forward pagination after a failed focus request is performed`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(false)
}
val room = aRoomWithDetachedTimeline(
paginateLambda = paginateLambda,
createTimelineResult = { Result.failure(RuntimeException("An error")) },
)
val presenter = createTimelinePresenter(
room = room,
timeline = room.liveTimeline,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
consumeItemsUntilPredicate { it.focusRequestState is FocusRequestState.Failure }
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.FORWARDS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a forward pagination in a thread timeline is ignored`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(true)
}
val timeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
this.paginateLambda = paginateLambda
}
val presenter = createTimelinePresenter(timeline = timeline)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
assert(paginateLambda).isNeverCalled()
// A backward pagination is still performed in a thread timeline
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
advanceUntilIdle()
assert(paginateLambda)
.isCalledOnce()
.with(value(Timeline.PaginationDirection.BACKWARDS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer()
@@ -1390,6 +1557,112 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - show and hide send failure dialog`() = runTest {
val presenter = createTimelinePresenter()
val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error"))
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
awaitItem().also { state ->
assertThat(state.sendFailureDialogState).isEqualTo(
SendFailureDialogState.Show(
event = event,
sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"),
)
)
state.eventSink(TimelineEvent.HideSendFailureDialog)
}
awaitItem().also { state ->
assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
}
}
}
@Test
fun `present - show send failure dialog - unknown error with a blank reason`() {
assertSendFailureType(
sendState = LocalEventSendState.Failed.Unknown(error = ""),
expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown,
)
}
@Test
fun `present - show send failure dialog - invalid mime type`() {
assertSendFailureType(
sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"),
expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
)
}
@Test
fun `present - show send failure dialog - missing media content`() {
assertSendFailureType(
sendState = LocalEventSendState.Failed.MissingMediaContent,
expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
)
}
@Test
fun `present - show send failure dialog - sending from unverified device`() {
assertSendFailureType(
sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice,
expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
)
}
private fun assertSendFailureType(
sendState: LocalEventSendState.Failed,
expectedSendFailureType: SendFailureDialogState.SendFailureType,
) = runTest {
val presenter = createTimelinePresenter()
val event = aMessageEvent(sendState = sendState)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
assertThat(awaitItem().sendFailureDialogState).isEqualTo(
SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest {
val resolveEventSinkRecorder = lambdaRecorder<ResolveVerifiedUserSendFailureEvent, Unit> { }
val presenter = createTimelinePresenter(
resolveVerifiedUserSendFailurePresenter = {
aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder)
},
)
val event = aMessageEvent(
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
// The generic dialog is not displayed, the dedicated one is.
assertThat(consumeItemsUntilTimeout()).isEmpty()
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
resolveEventSinkRecorder.assertions()
.isCalledOnce()
.with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event)))
}
}
@Test
fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest {
val presenter = createTimelinePresenter()
val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
assertThat(consumeItemsUntilTimeout()).isEmpty()
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
}
}
@Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
val timeline = FakeTimeline(
@@ -1552,6 +1825,37 @@ class TimelinePresenterTest {
return awaitItem()
}
private fun aRoomWithDetachedTimeline(
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
createTimelineResult: () -> Result<Timeline> = { Result.success(aDetachedTimeline(paginateLambda)) },
): FakeJoinedRoom {
return FakeJoinedRoom(
liveTimeline = FakeTimeline(timelineItems = flowOf(emptyList())).apply {
this.paginateLambda = paginateLambda
},
createTimelineResult = { createTimelineResult() },
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
}
private fun aDetachedTimeline(
paginateLambda: (Timeline.PaginationDirection) -> Result<Boolean>,
) = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(eventId = AN_EVENT_ID),
)
)
)
).apply {
this.paginateLambda = paginateLambda
}
private fun roomPermissions(
canRedactOther: Boolean = false,
canRedactOwn: Boolean = true,
@@ -1589,6 +1893,7 @@ class TimelinePresenterTest {
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -1602,7 +1907,7 @@ class TimelinePresenterTest {
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room, timeline),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
@@ -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>()
@@ -13,9 +13,11 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
@@ -122,6 +124,50 @@ class TimelineItemsFactoryTest {
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
}
@Test
fun `a key verification request is not rendered`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
content = aMessageContent(
body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.",
messageType = OtherMessageType(
msgType = "m.key.verification.request",
body = "Alice is requesting to verify your key, but your client does not support in-chat key verification.",
),
),
),
),
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-1"),
event = anEventTimelineItem(
sender = A_USER_ID,
content = aMessageContent(body = "A regular message"),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = emptyList(),
renderReadReceipts = false,
)
val bodies = awaitItem()
.filterIsInstance<TimelineItem.Event>()
.map { (it.content as TimelineItemTextContent).body }
assertThat(bodies).containsExactly("A regular message")
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
@@ -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)