Keep selection mode active until explicitly exited

This commit is contained in:
ganfra
2026-10-02 20:41:04 +02:00
parent 2d30dc6cf0
commit e99861a5c1
5 changed files with 204 additions and 38 deletions
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -194,6 +195,10 @@ fun MessagesView(
}
}
BackHandler(enabled = state.timelineState.isSelectionModeActive) {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
}
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId
@@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa
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.toPersistentSet
/** Maximum number of messages that can be selected at once. */
const val MAX_SELECTION_COUNT = 10
@@ -25,7 +26,10 @@ sealed interface SelectionState {
/** Selection mode is not active. */
data object Disabled : SelectionState
/** Selection mode is active for [action], with the given selection. */
/**
* Selection mode is active for [action], with the given selection.
* [selectedEventIds] can be empty: selection mode is never exited automatically, only when the user explicitly leaves it.
*/
data class Active(
val action: SelectionAction,
val selectedEventIds: ImmutableSet<EventId>,
@@ -46,3 +50,39 @@ enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRe
fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) {
SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded()
}
/**
* This selection without [eventIds].
* Selection mode stays active even when nothing remains selected, see [SelectionState.Active].
*/
fun SelectionState.deselect(eventIds: Set<EventId>): SelectionState = when (this) {
SelectionState.Disabled -> this
is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toPersistentSet())
}
/**
* The ids of the selected events which [SelectionState.Active.action] cannot be applied to anymore, for instance because the message has been
* deleted. They are meant to be given to [deselect].
*/
fun SelectionState.eventIdsToDeselect(timelineItems: List<TimelineItem>): Set<EventId> {
val selection = this as? SelectionState.Active ?: return emptySet()
if (selection.selectedEventIds.isEmpty()) return emptySet()
val result = mutableSetOf<EventId>()
fun process(event: TimelineItem.Event) {
val eventId = event.eventId ?: return
if (eventId in selection.selectedEventIds && !selection.action.canApplyTo(event)) {
result.add(eventId)
}
}
timelineItems.forEach { item ->
when (item) {
is TimelineItem.Event -> process(item)
// A deleted message can be folded into a group of deleted messages, so look inside the groups too.
is TimelineItem.GroupedEvents -> item.events.forEach(::process)
is TimelineItem.Virtual -> Unit
}
}
return result
}
@@ -77,9 +77,11 @@ import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -273,10 +275,8 @@ class TimelinePresenter(
val current = selectionState as? SelectionState.Active ?: return
val selected = current.selectedEventIds
selectionState = when {
event.eventId in selected -> {
val next = (selected - event.eventId).toPersistentSet()
if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next)
}
// Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS.
event.eventId in selected -> current.deselect(setOf(event.eventId))
selected.size >= MAX_SELECTION_COUNT -> {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected))
current
@@ -371,6 +371,14 @@ class TimelinePresenter(
}
.launchIn(this)
// Drop the selected events which the action cannot be applied to anymore, for instance because the message has been deleted.
timelineItemsFactory.timelineItems
.map { items -> selectionState.eventIdsToDeselect(items) }
.filter { eventIdsToDeselect -> eventIdsToDeselect.isNotEmpty() }
.flowOn(dispatchers.computation)
.onEach { eventIdsToDeselect -> selectionState = selectionState.deselect(eventIdsToDeselect) }
.launchIn(this)
combine(
timelineController.timelineItems(),
room.membersStateFlow,
@@ -396,26 +404,6 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}
val selectableEventIds = (selectionState as? SelectionState.Active)?.let { active ->
timelineItems
.filterIsInstance<TimelineItem.Event>()
.filter { active.action.canApplyTo(it) }
.mapNotNull { it.eventId }
.toSet()
}.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 -> active.copy(selectedEventIds = reconciled)
else -> active
}
}
// Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
// read marker advances in place — the SDK swaps the marker virtual item to a new position
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
@@ -0,0 +1,89 @@
/*
* 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
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.toPersistentSet
import org.junit.Test
class SelectionStateTest {
@Test
fun `eventIdsToDeselect - selection mode being disabled has nothing to drop`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent()))
assertThat(SelectionState.Disabled.eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - an empty selection has nothing to drop`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent()))
assertThat(anActiveSelection().eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a selected event the action still applies to is kept`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID))
assertThat(anActiveSelection(listOf(AN_EVENT_ID)).eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a selected event which has been deleted is dropped`() {
val items = listOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()),
aTimelineItemEvent(eventId = AN_EVENT_ID),
)
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `eventIdsToDeselect - a selected event which is not in the timeline is ignored`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID))
// AN_EVENT_ID_3 may simply not be loaded anymore, so it is not dropped here.
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_3)).eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a deleted selected event folded into a group is dropped`() {
val items = listOf(
TimelineItem.GroupedEvents(
id = UniqueId("group"),
events = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()),
aTimelineItemEvent(eventId = AN_EVENT_ID_3, content = aTimelineItemRedactedContent()),
),
aggregatedReadReceipts = persistentListOf(),
),
aTimelineItemEvent(eventId = AN_EVENT_ID),
)
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `deselect - removes the given events and keeps selection mode active`() {
val selection = anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2))
assertThat(selection.deselect(setOf(AN_EVENT_ID))).isEqualTo(anActiveSelection(listOf(AN_EVENT_ID_2)))
assertThat(selection.deselect(setOf(AN_EVENT_ID, AN_EVENT_ID_2))).isEqualTo(anActiveSelection())
}
@Test
fun `deselect - does nothing when selection mode is disabled`() {
assertThat(SelectionState.Disabled.deselect(setOf(AN_EVENT_ID))).isEqualTo(SelectionState.Disabled)
}
private fun anActiveSelection(eventIds: List<EventId> = emptyList()) = SelectionState.Active(
action = SelectionAction.Forward,
selectedEventIds = eventIds.toPersistentSet(),
)
}
@@ -50,10 +50,8 @@ 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
@@ -1862,7 +1860,7 @@ class TimelinePresenterTest {
}
@Test
fun `present - toggling the last selected event exits selection mode`() = runTest {
fun `present - toggling the last selected event keeps selection mode active`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
@@ -1875,6 +1873,28 @@ class TimelinePresenterTest {
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
// Selection mode is only exited explicitly, so an empty selection keeps it active.
val state = consumeItemsUntilPredicate { it.selectedCount == 0 }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf()))
assertThat(state.isSelectionModeActive).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - exiting selection mode clears the selection`() = 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 }
initialState.eventSink(TimelineEvent.ExitSelectionMode)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
@@ -1903,7 +1923,35 @@ class TimelinePresenterTest {
}
@Test
fun `present - selection drops events the action cannot apply to`() = runTest {
fun `present - a selected event which cannot be forwarded anymore is deselected`() = runTest {
val timelineItems = MutableStateFlow(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, 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))
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID_2))
consumeItemsUntilPredicate { it.selectedCount == 2 }
// The first message is deleted, so it cannot be forwarded anymore.
timelineItems.value = listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aRedactedContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
val state = consumeItemsUntilPredicate { it.selectedCount == 1 }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2)))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - the selection survives a timeline which no longer contains the selected event`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
@@ -1915,15 +1963,11 @@ class TimelinePresenterTest {
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)
// The timeline no longer holds the selected event, for instance because it has been reset or because it switched to a detached timeline.
timelineItems.value = emptyList()
val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last()
// The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it.
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
cancelAndIgnoreRemainingEvents()
}
}