mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 03:06:14 +00:00
Keep selection mode active until explicitly exited
This commit is contained in:
+5
@@ -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
|
||||
|
||||
+41
-1
@@ -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
|
||||
}
|
||||
|
||||
+12
-24
@@ -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
|
||||
|
||||
+89
@@ -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(),
|
||||
)
|
||||
}
|
||||
+57
-13
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user