Enter selection mode from the Forward action

This commit is contained in:
ganfra
2026-09-25 18:47:28 +02:00
parent a0bfdd379c
commit cec42ae4e6
20 changed files with 379 additions and 180 deletions
@@ -21,6 +21,7 @@ sealed interface MessagesEvent {
data object StopLiveLocationShare : MessagesEvent data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent
data object ConfirmSelectionAction : MessagesEvent
} }
enum class InviteDialogAction { enum class InviteDialogAction {
@@ -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.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState 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.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.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.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.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull 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.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.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
@@ -253,6 +256,17 @@ class MessagesPresenter(
timelineProtectionState = timelineProtectionState, 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 -> { is MessagesEvent.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
} }
@@ -395,17 +409,20 @@ class MessagesPresenter(
} }
} }
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) 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.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.ViewInTimeline -> Unit
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) 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) 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) { private fun handleReportAction(event: TimelineItem.Event) {
if (event.eventId == null) return if (event.eventId == null) return
navigator.navigateToReportMessage(event.eventId, event.senderId) navigator.navigateToReportMessage(event.eventId, event.senderId)
@@ -10,8 +10,6 @@ package io.element.android.features.messages.impl
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults 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.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.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.aGroupedEvents import io.element.android.features.messages.impl.timeline.aGroupedEvents
@@ -248,11 +247,16 @@ fun MessagesView(
topBar = { topBar = {
when { when {
state.timelineState.isSelectionModeActive -> { state.timelineState.isSelectionModeActive -> {
val selection = state.timelineState.selectionState as SelectionState.Active
SelectionModeTopBar( SelectionModeTopBar(
selectedCount = state.timelineState.selectedCount, selectedCount = state.timelineState.selectedCount,
action = selection.action,
onCancelClick = { onCancelClick = {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
}, },
onConfirmClick = {
state.eventSink(MessagesEvent.ConfirmSelectionAction)
},
) )
} }
state.timelineState.timelineMode is Timeline.Mode.Thread -> { state.timelineState.timelineMode is Timeline.Mode.Thread -> {
@@ -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.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
@@ -110,7 +109,6 @@ class DefaultActionListPresenter(
}.collectAsState(initial = persistentListOf()) }.collectAsState(initial = persistentListOf())
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false)
fun handleEvent(event: ActionListEvent) { fun handleEvent(event: ActionListEvent) {
when (event) { when (event) {
@@ -122,7 +120,6 @@ class DefaultActionListPresenter(
pinnedEventIds = pinnedEventIds, pinnedEventIds = pinnedEventIds,
target = target, target = target,
isThreadsEnabled = isThreadsEnabled.value, isThreadsEnabled = isThreadsEnabled.value,
isMultiSelectEnabled = isMultiSelectEnabled.value,
) )
} }
} }
@@ -140,7 +137,6 @@ class DefaultActionListPresenter(
pinnedEventIds: ImmutableList<EventId>, pinnedEventIds: ImmutableList<EventId>,
target: MutableState<ActionListState.Target>, target: MutableState<ActionListState.Target>,
isThreadsEnabled: Boolean, isThreadsEnabled: Boolean,
isMultiSelectEnabled: Boolean,
) = launch { ) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
@@ -150,7 +146,6 @@ class DefaultActionListPresenter(
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
isThreadsEnabled = isThreadsEnabled, isThreadsEnabled = isThreadsEnabled,
isMultiSelectEnabled = isMultiSelectEnabled,
) )
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
@@ -184,7 +179,6 @@ class DefaultActionListPresenter(
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,
isEventPinned: Boolean, isEventPinned: Boolean,
isThreadsEnabled: Boolean, isThreadsEnabled: Boolean,
isMultiSelectEnabled: Boolean,
): List<TimelineItemAction> { ): List<TimelineItemAction> {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildSet { return buildSet {
@@ -206,9 +200,6 @@ class DefaultActionListPresenter(
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
if (isMultiSelectEnabled && timelineItem.canBeSelected()) {
add(TimelineItemAction.Select)
}
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
if (timelineItem.content is TimelineItemEventContentWithAttachment || if (timelineItem.content is TimelineItemEventContentWithAttachment ||
timelineItem.content is TimelineItemGalleryContent || timelineItem.content is TimelineItemGalleryContent ||
@@ -20,7 +20,6 @@ enum class TimelineItemAction(
) { ) {
ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on), ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on),
Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward), 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), CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy),
CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy), CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy),
CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link), CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link),
@@ -19,7 +19,6 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread, TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Select,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.EditPoll, TimelineItemAction.EditPoll,
TimelineItemAction.AddCaption, TimelineItemAction.AddCaption,
@@ -7,18 +7,42 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable 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.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
/** Maximum number of messages that can be selected at once. */ /** Maximum number of messages that can be selected at once. */
const val MAX_SELECTION_COUNT = 30 const val MAX_SELECTION_COUNT = 10
@Immutable @Immutable
sealed interface SelectionState { sealed interface SelectionState {
/** Selection mode is not active. */ /** Selection mode is not active. */
data object Disabled : SelectionState data object Disabled : SelectionState
/** Selection mode is active with the given selection. */ /** Selection mode is active for [action], with the given selection. */
data class Active(val selectedEventIds: ImmutableSet<EventId>) : SelectionState data class Active(
val action: SelectionAction,
val selectedEventIds: ImmutableSet<EventId>,
) : 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()
} }
@@ -29,7 +29,7 @@ sealed interface TimelineEvent {
data object MarkAllAsRead : 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 class ToggleSelection(val eventId: EventId) : TimelineEvent
data object ExitSelectionMode : TimelineEvent data object ExitSelectionMode : TimelineEvent
@@ -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.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.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.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel 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.TimelineProtectionEvent
@@ -265,7 +264,7 @@ class TimelinePresenter(
} }
is TimelineEvent.EnterSelectionMode -> { is TimelineEvent.EnterSelectionMode -> {
if (!isMultiSelectEnabled) return if (!isMultiSelectEnabled) return
selectionState = SelectionState.Active(persistentSetOf(event.eventId)) selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId))
} }
is TimelineEvent.ToggleSelection -> { is TimelineEvent.ToggleSelection -> {
val current = selectionState as? SelectionState.Active ?: return val current = selectionState as? SelectionState.Active ?: return
@@ -273,13 +272,13 @@ class TimelinePresenter(
selectionState = when { selectionState = when {
event.eventId in selected -> { event.eventId in selected -> {
val next = (selected - event.eventId).toPersistentSet() 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 -> { selected.size >= MAX_SELECTION_COUNT -> {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached)) snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached))
current current
} }
else -> SelectionState.Active((selected + event.eventId).toPersistentSet()) else -> current.copy(selectedEventIds = (selected + event.eventId).toPersistentSet())
} }
} }
is TimelineEvent.ExitSelectionMode -> { is TimelineEvent.ExitSelectionMode -> {
@@ -394,19 +393,22 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
} }
// Reconcile the selection when the items change: drop any selected event that is no longer val selectableEventIds = (selectionState as? SelectionState.Active)?.let { active ->
// present or no longer selectable, and exit selection mode if nothing remains selected. timelineItems
LaunchedEffect(timelineItems.map { it.identifier() }) {
val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect
val stillSelectable = timelineItems
.filterIsInstance<TimelineItem.Event>() .filterIsInstance<TimelineItem.Event>()
.filter { it.canBeSelected() } .filter { active.action.canApplyTo(it) }
.mapNotNull { it.eventId } .mapNotNull { it.eventId }
.toSet() .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 { selectionState = when {
reconciled.isEmpty() -> SelectionState.Disabled reconciled.isEmpty() -> SelectionState.Disabled
reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled) reconciled.size != active.selectedEventIds.size -> active.copy(selectedEventIds = reconciled)
else -> active else -> active
} }
} }
@@ -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.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration import kotlin.time.Duration
data class TimelineState( data class TimelineState(
@@ -108,7 +110,36 @@ val TimelineState.isSelectionModeActive: Boolean
val TimelineState.selectedCount: Int val TimelineState.selectedCount: Int
get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0 get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0
fun TimelineState.isSelected(eventId: EventId?): Boolean { fun TimelineState.isSelected(item: TimelineItem): Boolean {
if (eventId == null) return false val selection = selectionState as? SelectionState.Active ?: return false
return (selectionState as? SelectionState.Active)?.selectedEventIds?.contains(eventId) == true 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<EventId> {
val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf()
return timelineItems
.filterIsInstance<TimelineItem.Event>()
.mapNotNull { it.eventId }
.filter { it in selectedEventIds }
// Most recent item is first in the timeline, so reverse the result.
.reversed()
.toImmutableList()
} }
@@ -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.focus.FocusRequestStateView
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
@@ -234,17 +233,18 @@ fun TimelineView(
selectionData = TimelineItemSelectionData( selectionData = TimelineItemSelectionData(
isSelectionModeActive = isSelectionModeActive, isSelectionModeActive = isSelectionModeActive,
progress = selectionProgress, progress = selectionProgress,
isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId), isSelected = state.isSelected(timelineItem),
canBeSelected = state.canSelect(timelineItem),
), ),
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick, onLinkLongClick = ::onLinkLongClick,
onContentClick = { event -> onContentClick = { event ->
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event) if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
}, },
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLongClick = { event -> onLongClick = { event ->
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event) if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
}, },
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -646,14 +646,14 @@ internal fun TimelineViewPreview(
@Composable @Composable
internal fun TimelineViewSelectionModePreview() = ElementPreview { internal fun TimelineViewSelectionModePreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent()) val timelineItems = aTimelineItemList(aTimelineItemTextContent())
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { it.canBeSelected() }.eventId!! val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { SelectionAction.Forward.canApplyTo(it) }.eventId!!
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) { ) {
TimelineView( TimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
selectionState = SelectionState.Active(persistentSetOf(selectedEventId)), selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)),
), ),
timelineProtectionState = aTimelineProtectionState(), timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {}, onUserDataClick = {},
@@ -20,5 +20,6 @@ package io.element.android.features.messages.impl.timeline.components
data class TimelineItemSelectionData( data class TimelineItemSelectionData(
val isSelectionModeActive: Boolean = false, val isSelectionModeActive: Boolean = false,
val isSelected: Boolean = false, val isSelected: Boolean = false,
val canBeSelected: Boolean = false,
val progress: Float = 0f, val progress: Float = 0f,
) )
@@ -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
}
}
@@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@@ -28,7 +29,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun SelectionModeTopBar( fun SelectionModeTopBar(
selectedCount: Int, selectedCount: Int,
action: SelectionAction,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
onConfirmClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
TopAppBar( TopAppBar(
@@ -43,6 +46,17 @@ fun SelectionModeTopBar(
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close)) 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( internal fun SelectionModeTopBarPreview(
@PreviewParameter(SelectionCountProvider::class) count: Int, @PreviewParameter(SelectionCountProvider::class) count: Int,
) = ElementPreview { ) = ElementPreview {
SelectionModeTopBar(selectedCount = count, onCancelClick = {}) SelectionModeTopBar(
selectedCount = count,
action = SelectionAction.Forward,
onCancelClick = {},
onConfirmClick = {},
)
} }
internal class SelectionCountProvider : PreviewParameterProvider<Int> { internal class SelectionCountProvider : PreviewParameterProvider<Int> {
@@ -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.threads.list.aThreadListItem
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead 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.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.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.aTimelineState
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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
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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.testCoroutineDispatchers
import io.element.android.tests.testutils.testWithLifecycleOwner import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -285,17 +291,37 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - handle action forward`() = runTest { fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<EventId, Unit> { } val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
val navigator = FakeMessagesNavigator( val presenter = createMessagesPresenter(
onForwardEventClickLambda = onForwardEventClickLambda, timelineEventSink = timelineEventSink,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
) )
val presenter = createMessagesPresenter(navigator = navigator)
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) 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<List<EventId>, 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, canPinUnpin = canPinUnpin,
) )
@Test
fun `present - confirm selection action forwards the selection in timeline order`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, Unit> { }
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
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<List<EventId>, 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( private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(), timeline: Timeline = FakeTimeline(),
@@ -1446,6 +1515,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
timelineEventSink: (TimelineEvent) -> Unit = {}, timelineEventSink: (TimelineEvent) -> Unit = {},
timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink),
permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
messageComposerPresenter: Presenter<MessageComposerState> = Presenter { messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
aMessageComposerState( aMessageComposerState(
@@ -1468,7 +1538,7 @@ class MessagesPresenterTest {
room = joinedRoom, room = joinedRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, timelinePresenter = { timelineState },
timelineProtectionPresenter = { aTimelineProtectionState() }, timelineProtectionPresenter = { aTimelineProtectionState() },
identityChangeStatePresenter = { anIdentityChangeState() }, identityChangeStatePresenter = { anIdentityChangeState() },
linkPresenter = { aLinkState() }, linkPresenter = { aLinkState() },
@@ -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 @Test
fun `present - compute for message from me redacted`() = runTest { fun `present - compute for message from me redacted`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
@@ -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()
}
}
@@ -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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction 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.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.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt 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.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -1835,9 +1837,9 @@ class TimelinePresenterTest {
presenter.test { presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) 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() 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) assertThat(state.selectedCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@@ -1849,7 +1851,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(featureFlagService = featureFlagService) val presenter = createTimelinePresenter(featureFlagService = featureFlagService)
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
expectNoEvents() expectNoEvents()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -1867,7 +1869,7 @@ class TimelinePresenterTest {
) )
presenter.test { presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() 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 } consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
@@ -1888,7 +1890,7 @@ class TimelinePresenterTest {
) )
presenter.test { presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() 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 } consumeItemsUntilPredicate { it.isSelectionModeActive }
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() 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 <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem() return awaitItem()
} }
@@ -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()
}
}
@@ -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()
}
}