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 package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically 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) { fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}") Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId 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.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toPersistentSet
/** 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 = 10 const val MAX_SELECTION_COUNT = 10
@@ -25,7 +26,10 @@ 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 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( data class Active(
val action: SelectionAction, val action: SelectionAction,
val selectedEventIds: ImmutableSet<EventId>, 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) { fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) {
SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded() 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.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -273,10 +275,8 @@ class TimelinePresenter(
val current = selectionState as? SelectionState.Active ?: return val current = selectionState as? SelectionState.Active ?: return
val selected = current.selectedEventIds val selected = current.selectedEventIds
selectionState = when { selectionState = when {
event.eventId in selected -> { // Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS.
val next = (selected - event.eventId).toPersistentSet() event.eventId in selected -> current.deselect(setOf(event.eventId))
if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next)
}
selected.size >= MAX_SELECTION_COUNT -> { selected.size >= MAX_SELECTION_COUNT -> {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected))
current current
@@ -371,6 +371,14 @@ class TimelinePresenter(
} }
.launchIn(this) .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( combine(
timelineController.timelineItems(), timelineController.timelineItems(),
room.membersStateFlow, room.membersStateFlow,
@@ -396,26 +404,6 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) 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 // 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 // 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 // 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.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
@@ -1862,7 +1860,7 @@ class TimelinePresenterTest {
} }
@Test @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( val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) 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)) 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))
// 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() val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -1903,7 +1923,35 @@ class TimelinePresenterTest {
} }
@Test @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( val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
) )
@@ -1915,15 +1963,11 @@ class TimelinePresenterTest {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive } consumeItemsUntilPredicate { it.isSelectionModeActive }
// The message is replaced by a state event, which cannot be forwarded. // 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 = listOf( timelineItems.value = emptyList()
MatrixTimelineItem.Event( val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last()
UniqueId("1"), // The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it.
anEventTimelineItem(eventId = AN_EVENT_ID, content = StateContent("", OtherState.RoomCreate)), assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
)
)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }