diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 4d621e417f..adbf1f15ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -21,6 +21,7 @@ sealed interface MessagesEvent { data object StopLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent + data object ConfirmSelectionAction : MessagesEvent } enum class InviteDialogAction { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index bcbfc35255..5a24c64787 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -39,6 +39,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineState @@ -53,6 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.captionOrNull import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent @@ -253,6 +256,17 @@ class MessagesPresenter( timelineProtectionState = timelineProtectionState, ) } + is MessagesEvent.ConfirmSelectionAction -> { + val selection = timelineState.selectionState as? SelectionState.Active + if (selection != null) { + when (selection.action) { + SelectionAction.Forward -> { + navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder()) + } + } + timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + } is MessagesEvent.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } @@ -395,17 +409,20 @@ class MessagesPresenter( } } TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) - TimelineItemAction.Forward -> handleForwardAction(targetEvent) + TimelineItemAction.Forward -> { + val eventId = targetEvent.eventId ?: return@launch + if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) { + timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId)) + } else { + navigator.forwardEvents(listOf(eventId)) + } + } TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) - TimelineItemAction.Select -> { - val eventId = targetEvent.eventId ?: return@launch - timelineState.eventSink(TimelineEvent.EnterSelectionMode(eventId)) - } } } @@ -588,11 +605,6 @@ class MessagesPresenter( navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo) } - private fun handleForwardAction(event: TimelineItem.Event) { - if (event.eventId == null) return - navigator.forwardEvent(event.eventId) - } - private fun handleReportAction(event: TimelineItem.Event) { if (event.eventId == null) return navigator.navigateToReportMessage(event.eventId, event.senderId) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index c4bcf552b5..0a82e52d08 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -10,8 +10,6 @@ package io.element.android.features.messages.impl import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -79,6 +77,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.aGroupedEvents @@ -248,11 +247,16 @@ fun MessagesView( topBar = { when { state.timelineState.isSelectionModeActive -> { + val selection = state.timelineState.selectionState as SelectionState.Active SelectionModeTopBar( selectedCount = state.timelineState.selectedCount, + action = selection.action, onCancelClick = { state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) }, + onConfirmClick = { + state.eventSink(MessagesEvent.ConfirmSelectionAction) + }, ) } state.timelineState.timelineMode is Timeline.Mode.Thread -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 66ed7dd757..8caa95f31d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo -import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment @@ -110,7 +109,6 @@ class DefaultActionListPresenter( }.collectAsState(initial = persistentListOf()) val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) - val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false) fun handleEvent(event: ActionListEvent) { when (event) { @@ -122,7 +120,6 @@ class DefaultActionListPresenter( pinnedEventIds = pinnedEventIds, target = target, isThreadsEnabled = isThreadsEnabled.value, - isMultiSelectEnabled = isMultiSelectEnabled.value, ) } } @@ -140,7 +137,6 @@ class DefaultActionListPresenter( pinnedEventIds: ImmutableList, target: MutableState, isThreadsEnabled: Boolean, - isMultiSelectEnabled: Boolean, ) = launch { target.value = ActionListState.Target.Loading(timelineItem) @@ -150,7 +146,6 @@ class DefaultActionListPresenter( isDeveloperModeEnabled = isDeveloperModeEnabled, isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isThreadsEnabled = isThreadsEnabled, - isMultiSelectEnabled = isMultiSelectEnabled, ) val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) @@ -184,7 +179,6 @@ class DefaultActionListPresenter( isDeveloperModeEnabled: Boolean, isEventPinned: Boolean, isThreadsEnabled: Boolean, - isMultiSelectEnabled: Boolean, ): List { val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther return buildSet { @@ -206,9 +200,6 @@ class DefaultActionListPresenter( if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { add(TimelineItemAction.Forward) } - if (isMultiSelectEnabled && timelineItem.canBeSelected()) { - add(TimelineItemAction.Select) - } if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.content is TimelineItemEventContentWithAttachment || timelineItem.content is TimelineItemGalleryContent || diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index c7ee198ebc..3c64223e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -20,7 +20,6 @@ enum class TimelineItemAction( ) { ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on), Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward), - Select(CommonStrings.action_select, CompoundDrawables.ic_compound_check), CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy), CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy), CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 86b62794ba..2f6fa4e4e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -19,7 +19,6 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.Reply, TimelineItemAction.ReplyInThread, TimelineItemAction.Forward, - TimelineItemAction.Select, TimelineItemAction.Edit, TimelineItemAction.EditPoll, TimelineItemAction.AddCaption, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt index 91b0d2e73a..f2738480aa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -7,18 +7,42 @@ package io.element.android.features.messages.impl.timeline +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.runtime.Immutable +import io.element.android.compound.R +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableSet /** Maximum number of messages that can be selected at once. */ -const val MAX_SELECTION_COUNT = 30 +const val MAX_SELECTION_COUNT = 10 @Immutable sealed interface SelectionState { /** Selection mode is not active. */ data object Disabled : SelectionState - /** Selection mode is active with the given selection. */ - data class Active(val selectedEventIds: ImmutableSet) : SelectionState + /** Selection mode is active for [action], with the given selection. */ + data class Active( + val action: SelectionAction, + val selectedEventIds: ImmutableSet, + ) : SelectionState +} + +/** + * An action which can be performed on several timeline events at once, and which selection mode can be entered for. + */ +enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRes val iconRes: Int) { + Forward(CommonStrings.action_forward, R.drawable.ic_compound_forward), +} + +/** + * Whether [event] can be part of a selection made for this action. + * Each action uses the same criteria as the single event action it mirrors. + */ +fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) { + SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index f21bfe88aa..ac67d93784 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -29,7 +29,7 @@ sealed interface TimelineEvent { data object MarkAllAsRead : TimelineEvent - data class EnterSelectionMode(val eventId: EventId) : TimelineEvent + data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent data class ToggleSelection(val eventId: EventId) : TimelineEvent data object ExitSelectionMode : TimelineEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 80812264fd..320e362024 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -34,7 +34,6 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig 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.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent @@ -265,7 +264,7 @@ class TimelinePresenter( } is TimelineEvent.EnterSelectionMode -> { if (!isMultiSelectEnabled) return - selectionState = SelectionState.Active(persistentSetOf(event.eventId)) + selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId)) } is TimelineEvent.ToggleSelection -> { val current = selectionState as? SelectionState.Active ?: return @@ -273,13 +272,13 @@ class TimelinePresenter( selectionState = when { event.eventId in selected -> { val next = (selected - event.eventId).toPersistentSet() - if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(next) + if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next) } selected.size >= MAX_SELECTION_COUNT -> { snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached)) current } - else -> SelectionState.Active((selected + event.eventId).toPersistentSet()) + else -> current.copy(selectedEventIds = (selected + event.eventId).toPersistentSet()) } } is TimelineEvent.ExitSelectionMode -> { @@ -394,19 +393,22 @@ class TimelinePresenter( computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) } - // Reconcile the selection when the items change: drop any selected event that is no longer - // present or no longer selectable, and exit selection mode if nothing remains selected. - LaunchedEffect(timelineItems.map { it.identifier() }) { - val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect - val stillSelectable = timelineItems + val selectableEventIds = (selectionState as? SelectionState.Active)?.let { active -> + timelineItems .filterIsInstance() - .filter { it.canBeSelected() } + .filter { active.action.canApplyTo(it) } .mapNotNull { it.eventId } .toSet() - val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet() + }.orEmpty() + + // Reconcile the selection when the items change: drop any selected event that is no longer + // present or no longer selectable, and exit selection mode if nothing remains selected. + LaunchedEffect(selectableEventIds) { + val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect + val reconciled = active.selectedEventIds.filter { it in selectableEventIds }.toPersistentSet() selectionState = when { reconciled.isEmpty() -> SelectionState.Disabled - reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled) + reconciled.size != active.selectedEventIds.size -> active.copy(selectedEventIds = reconciled) else -> active } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 6bd5aa0895..bc571f6634 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -21,6 +21,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlin.time.Duration data class TimelineState( @@ -108,7 +110,36 @@ val TimelineState.isSelectionModeActive: Boolean val TimelineState.selectedCount: Int get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0 -fun TimelineState.isSelected(eventId: EventId?): Boolean { - if (eventId == null) return false - return (selectionState as? SelectionState.Active)?.selectedEventIds?.contains(eventId) == true +fun TimelineState.isSelected(item: TimelineItem): Boolean { + val selection = selectionState as? SelectionState.Active ?: return false + return when(item){ + is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId) + else -> false + } +} + +/** + * Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event. + */ +fun TimelineState.canSelect(item: TimelineItem): Boolean { + val selection = selectionState as? SelectionState.Active ?: return false + return when(item) { + is TimelineItem.Event -> selection.action.canApplyTo(item) + else -> false + } +} + +/** + * The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline. + * The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on. + */ +fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList { + val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf() + return timelineItems + .filterIsInstance() + .mapNotNull { it.eventId } + .filter { it in selectedEventIds } + // Most recent item is first in the timeline, so reverse the result. + .reversed() + .toImmutableList() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0df9be3ca0..eb9eef7c8f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -91,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView 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.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent @@ -234,17 +233,18 @@ fun TimelineView( selectionData = TimelineItemSelectionData( isSelectionModeActive = isSelectionModeActive, progress = selectionProgress, - isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId), + isSelected = state.isSelected(timelineItem), + canBeSelected = state.canSelect(timelineItem), ), onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, onContentClick = { event -> - if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event) + if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event) }, onGalleryItemClick = onGalleryItemClick, onLongClick = { event -> - if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event) + if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event) }, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, @@ -646,14 +646,14 @@ internal fun TimelineViewPreview( @Composable internal fun TimelineViewSelectionModePreview() = ElementPreview { val timelineItems = aTimelineItemList(aTimelineItemTextContent()) - val selectedEventId = timelineItems.filterIsInstance().first { it.canBeSelected() }.eventId!! + val selectedEventId = timelineItems.filterIsInstance().first { SelectionAction.Forward.canApplyTo(it) }.eventId!! CompositionLocalProvider( LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), ) { TimelineView( state = aTimelineState( timelineItems = timelineItems, - selectionState = SelectionState.Active(persistentSetOf(selectedEventId)), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)), ), timelineProtectionState = aTimelineProtectionState(), onUserDataClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt index f8de354aac..641be44fe8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt @@ -20,5 +20,6 @@ package io.element.android.features.messages.impl.timeline.components data class TimelineItemSelectionData( val isSelectionModeActive: Boolean = false, val isSelected: Boolean = false, + val canBeSelected: Boolean = false, val progress: Float = 0f, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt deleted file mode 100644 index 9f11e86213..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2026 New Vector 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.model - -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent - -/** - * Whether this timeline event can be selected in multi-select ("selection") mode. - */ -fun TimelineItem.Event.canBeSelected(): Boolean { - // Local echoes (no remote eventId) and failed sends are never selectable. - if (!isRemote || failedToSend) return false - return when (content) { - is TimelineItemStateContent, - is TimelineItemRedactedContent, - is TimelineItemEncryptedContent, - is TimelineItemLegacyCallInviteContent, - is TimelineItemRtcNotificationContent, - is TimelineItemLocationContent, - is TimelineItemUnknownContent -> false - else -> true - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index 55563d1f1b..1e96d7b1e1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.SelectionAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -28,7 +29,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SelectionModeTopBar( selectedCount: Int, + action: SelectionAction, onCancelClick: () -> Unit, + onConfirmClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -43,6 +46,17 @@ fun SelectionModeTopBar( Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close)) } }, + actions = { + IconButton( + onClick = onConfirmClick, + enabled = selectedCount > 0, + ) { + Icon( + resourceId = action.iconRes, + contentDescription = stringResource(action.titleRes), + ) + } + }, ) } @@ -51,7 +65,12 @@ fun SelectionModeTopBar( internal fun SelectionModeTopBarPreview( @PreviewParameter(SelectionCountProvider::class) count: Int, ) = ElementPreview { - SelectionModeTopBar(selectedCount = count, onCancelClick = {}) + SelectionModeTopBar( + selectedCount = count, + action = SelectionAction.Forward, + onCancelClick = {}, + onConfirmClick = {}, + ) } internal class SelectionCountProvider : PreviewParameterProvider { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 4c0abc0a34..152cb8a7a0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess import io.element.android.features.messages.impl.threads.list.aThreadListItem import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvent +import io.element.android.features.messages.impl.timeline.TimelineState +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -76,6 +80,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen 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 +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -113,6 +118,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testWithLifecycleOwner import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -285,17 +291,37 @@ class MessagesPresenterTest { } @Test - fun `present - handle action forward`() = runTest { - val onForwardEventClickLambda = lambdaRecorder { } - val navigator = FakeMessagesNavigator( - onForwardEventClickLambda = onForwardEventClickLambda, + fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest { + val timelineEventSink = lambdaRecorder { } + val presenter = createMessagesPresenter( + timelineEventSink = timelineEventSink, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), ) - val presenter = createMessagesPresenter(navigator = navigator) presenter.testWithLifecycleOwner { val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) - onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + timelineEventSink.assertions().isCalledOnce() + .with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle action forward navigates directly when multi select is disabled`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, Unit> { } + val navigator = FakeMessagesNavigator( + onForwardEventClickLambda = onForwardEventClickLambda, + ) + val presenter = createMessagesPresenter( + navigator = navigator, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID))) } } @@ -1424,6 +1450,49 @@ class MessagesPresenterTest { canPinUnpin = canPinUnpin, ) + @Test + fun `present - confirm selection action forwards the selection in timeline order`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, Unit> { } + val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda) + val timelineEventSink = lambdaRecorder { } + val presenter = createMessagesPresenter( + navigator = navigator, + timelineEventSink = timelineEventSink, + timelineState = aTimelineState( + // The timeline is rendered from the most recent event to the oldest one. + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + selectionState = SelectionState.Active( + SelectionAction.Forward, + persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID), + ), + eventSink = timelineEventSink, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.ConfirmSelectionAction) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2))) + timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - confirm selection action does nothing when no selection is active`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, Unit> { } + val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.ConfirmSelectionAction) + onForwardEventClickLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + private fun TestScope.createMessagesPresenter( coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), timeline: Timeline = FakeTimeline(), @@ -1446,6 +1515,7 @@ class MessagesPresenterTest { clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), timelineEventSink: (TimelineEvent) -> Unit = {}, + timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink), permalinkParser: PermalinkParser = FakePermalinkParser(), messageComposerPresenter: Presenter = Presenter { aMessageComposerState( @@ -1468,7 +1538,7 @@ class MessagesPresenterTest { room = joinedRoom, composerPresenter = messageComposerPresenter, voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), - timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, + timelinePresenter = { timelineState }, timelineProtectionPresenter = { aTimelineProtectionState() }, identityChangeStatePresenter = { anIdentityChangeState() }, linkPresenter = { aLinkState() }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 2988fbe894..e987a32480 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -70,44 +70,6 @@ class ActionListPresenterTest { } } - @Test - fun `present - Select action is present for a remote message when multi-select is enabled`() = runTest { - val presenter = createActionListPresenter( - isDeveloperModeEnabled = false, - featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke( - ActionListEvent.ComputeForMessage( - event = aMessageEvent(), - userEventPermissions = aUserEventPermissions(canSendMessage = true), - ) - ) - val successState = awaitItem() - assertThat((successState.target as ActionListState.Target.Success).actions).contains(TimelineItemAction.Select) - } - } - - @Test - fun `present - Select action is absent when multi-select is disabled`() = runTest { - val presenter = createActionListPresenter( - isDeveloperModeEnabled = false, - featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke( - ActionListEvent.ComputeForMessage( - event = aMessageEvent(), - userEventPermissions = aUserEventPermissions(canSendMessage = true), - ) - ) - val successState = awaitItem() - assertThat((successState.target as ActionListState.Target.Success).actions).doesNotContain(TimelineItemAction.Select) - } - } - @Test fun `present - compute for message from me redacted`() = runTest { val presenter = createActionListPresenter(isDeveloperModeEnabled = true) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt new file mode 100644 index 0000000000..4939ec3a50 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 New Vector 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import org.junit.Test + +class SelectionActionTest { + @Test + fun `Forward - a remote text message can be selected`() { + assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent())).isTrue() + } + + @Test + fun `Forward - a local echo cannot be selected`() { + assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent().copy(eventId = null))).isFalse() + } + + @Test + fun `Forward - a failed to send message cannot be selected`() { + // A message which failed to send is a local echo: it has no event id. + val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")).copy(eventId = null) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a state event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemStateEventContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a redacted event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemRedactedContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a sticker cannot be selected, it cannot be forwarded`() { + val item = aTimelineItemEvent(content = aTimelineItemStickerContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a static location can be selected, it can be forwarded`() { + val item = aTimelineItemEvent(content = aTimelineItemLocationContent(mode = aStaticLocationMode())) + assertThat(SelectionAction.Forward.canApplyTo(item)).isTrue() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 6dcf107e0b..d6534deb23 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -50,8 +50,10 @@ 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.OtherState 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.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -1835,9 +1837,9 @@ class TimelinePresenterTest { presenter.test { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last() - assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID))) + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID))) assertThat(state.selectedCount).isEqualTo(1) cancelAndIgnoreRemainingEvents() } @@ -1849,7 +1851,7 @@ class TimelinePresenterTest { val presenter = createTimelinePresenter(featureFlagService = featureFlagService) presenter.test { val initialState = awaitFirstItem() - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) expectNoEvents() assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) cancelAndIgnoreRemainingEvents() @@ -1867,7 +1869,7 @@ class TimelinePresenterTest { ) presenter.test { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) consumeItemsUntilPredicate { it.isSelectionModeActive } initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() @@ -1888,7 +1890,7 @@ class TimelinePresenterTest { ) presenter.test { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) consumeItemsUntilPredicate { it.isSelectionModeActive } featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() @@ -1897,6 +1899,32 @@ class TimelinePresenterTest { } } + @Test + fun `present - selection drops events the action cannot apply to`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + // The message is replaced by a state event, which cannot be forwarded. + timelineItems.value = listOf( + MatrixTimelineItem.Event( + UniqueId("1"), + anEventTimelineItem(eventId = AN_EVENT_ID, content = StateContent("", OtherState.RoomCreate)), + ) + ) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt new file mode 100644 index 0000000000..3ee818e438 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 New Vector 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.AN_EVENT_ID_3 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test + +class TimelineStateTest { + @Test + fun `selectedEventIdsInTimelineOrder - when selection is disabled, the list is empty`() { + val state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(eventId = AN_EVENT_ID)), + selectionState = SelectionState.Disabled, + ) + assertThat(state.selectedEventIdsInTimelineOrder()).isEmpty() + } + + @Test + fun `selectedEventIdsInTimelineOrder - the events are sorted from the oldest to the most recent one`() { + // The timeline items are rendered from the most recent to the oldest event. + val state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_3), + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + // The events have been tapped in a random order. + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID, AN_EVENT_ID_3)), + ) + assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID, AN_EVENT_ID_2, AN_EVENT_ID_3).inOrder() + } + + @Test + fun `selectedEventIdsInTimelineOrder - the events which are not in the timeline are ignored`() { + val state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID_3)), + ) + assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `canSelect - returns false when selection mode is disabled`() { + val event = aTimelineItemEvent(eventId = AN_EVENT_ID) + val state = aTimelineState( + timelineItems = persistentListOf(event), + selectionState = SelectionState.Disabled, + ) + assertThat(state.canSelect(event)).isFalse() + } + + @Test + fun `canSelect - follows the action predicate when selection mode is active`() { + val forwardable = aTimelineItemEvent(eventId = AN_EVENT_ID) + val notForwardable = aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()) + val state = aTimelineState( + timelineItems = persistentListOf(forwardable, notForwardable), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)), + ) + assertThat(state.canSelect(forwardable)).isTrue() + assertThat(state.canSelect(notForwardable)).isFalse() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt deleted file mode 100644 index e293246900..0000000000 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2026 New Vector 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.model - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.impl.timeline.aTimelineItemEvent -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState -import org.junit.Test - -class TimelineItemSelectionTest { - @Test - fun `a remote text message can be selected`() { - val item = aTimelineItemEvent() - assertThat(item.canBeSelected()).isTrue() - } - - @Test - fun `a local echo cannot be selected`() { - val item = aTimelineItemEvent().copy(eventId = null) - assertThat(item.canBeSelected()).isFalse() - } - - @Test - fun `a failed to send message cannot be selected`() { - val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")) - assertThat(item.canBeSelected()).isFalse() - } - - @Test - fun `a state event cannot be selected`() { - val item = aTimelineItemEvent(content = aTimelineItemStateEventContent()) - assertThat(item.canBeSelected()).isFalse() - } - - @Test - fun `a redacted event cannot be selected`() { - val item = aTimelineItemEvent(content = aTimelineItemRedactedContent()) - assertThat(item.canBeSelected()).isFalse() - } -}