mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Enter selection mode from the Forward action
This commit is contained in:
+1
@@ -21,6 +21,7 @@ sealed interface MessagesEvent {
|
||||
data object StopLiveLocationShare : MessagesEvent
|
||||
data object ShowLiveLocationShare : MessagesEvent
|
||||
data object MarkAsFullyReadAndExit : MessagesEvent
|
||||
data object ConfirmSelectionAction : MessagesEvent
|
||||
}
|
||||
|
||||
enum class InviteDialogAction {
|
||||
|
||||
+22
-10
@@ -39,6 +39,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.SelectionAction
|
||||
import io.element.android.features.messages.impl.timeline.SelectionState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
@@ -53,6 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
|
||||
@@ -253,6 +256,17 @@ class MessagesPresenter(
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
)
|
||||
}
|
||||
is MessagesEvent.ConfirmSelectionAction -> {
|
||||
val selection = timelineState.selectionState as? SelectionState.Active
|
||||
if (selection != null) {
|
||||
when (selection.action) {
|
||||
SelectionAction.Forward -> {
|
||||
navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder())
|
||||
}
|
||||
}
|
||||
timelineState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
}
|
||||
}
|
||||
is MessagesEvent.ToggleReaction -> {
|
||||
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
|
||||
}
|
||||
@@ -395,17 +409,20 @@ class MessagesPresenter(
|
||||
}
|
||||
}
|
||||
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
|
||||
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||
TimelineItemAction.Forward -> {
|
||||
val eventId = targetEvent.eventId ?: return@launch
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) {
|
||||
timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId))
|
||||
} else {
|
||||
navigator.forwardEvents(listOf(eventId))
|
||||
}
|
||||
}
|
||||
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
|
||||
TimelineItemAction.Pin -> handlePinAction(targetEvent)
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> Unit
|
||||
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
|
||||
TimelineItemAction.Select -> {
|
||||
val eventId = targetEvent.eventId ?: return@launch
|
||||
timelineState.eventSink(TimelineEvent.EnterSelectionMode(eventId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,11 +605,6 @@ class MessagesPresenter(
|
||||
navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo)
|
||||
}
|
||||
|
||||
private fun handleForwardAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.forwardEvent(event.eventId)
|
||||
}
|
||||
|
||||
private fun handleReportAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.navigateToReportMessage(event.eventId, event.senderId)
|
||||
|
||||
+6
-2
@@ -10,8 +10,6 @@ package io.element.android.features.messages.impl
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -79,6 +77,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
|
||||
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
|
||||
import io.element.android.features.messages.impl.timeline.SelectionState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||
import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
||||
@@ -248,11 +247,16 @@ fun MessagesView(
|
||||
topBar = {
|
||||
when {
|
||||
state.timelineState.isSelectionModeActive -> {
|
||||
val selection = state.timelineState.selectionState as SelectionState.Active
|
||||
SelectionModeTopBar(
|
||||
selectedCount = state.timelineState.selectedCount,
|
||||
action = selection.action,
|
||||
onCancelClick = {
|
||||
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
},
|
||||
onConfirmClick = {
|
||||
state.eventSink(MessagesEvent.ConfirmSelectionAction)
|
||||
},
|
||||
)
|
||||
}
|
||||
state.timelineState.timelineMode is Timeline.Mode.Thread -> {
|
||||
|
||||
-9
@@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.canBeSelected
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
@@ -110,7 +109,6 @@ class DefaultActionListPresenter(
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
||||
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||
val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false)
|
||||
|
||||
fun handleEvent(event: ActionListEvent) {
|
||||
when (event) {
|
||||
@@ -122,7 +120,6 @@ class DefaultActionListPresenter(
|
||||
pinnedEventIds = pinnedEventIds,
|
||||
target = target,
|
||||
isThreadsEnabled = isThreadsEnabled.value,
|
||||
isMultiSelectEnabled = isMultiSelectEnabled.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +137,6 @@ class DefaultActionListPresenter(
|
||||
pinnedEventIds: ImmutableList<EventId>,
|
||||
target: MutableState<ActionListState.Target>,
|
||||
isThreadsEnabled: Boolean,
|
||||
isMultiSelectEnabled: Boolean,
|
||||
) = launch {
|
||||
target.value = ActionListState.Target.Loading(timelineItem)
|
||||
|
||||
@@ -150,7 +146,6 @@ class DefaultActionListPresenter(
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
|
||||
isThreadsEnabled = isThreadsEnabled,
|
||||
isMultiSelectEnabled = isMultiSelectEnabled,
|
||||
)
|
||||
|
||||
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
|
||||
@@ -184,7 +179,6 @@ class DefaultActionListPresenter(
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
isEventPinned: Boolean,
|
||||
isThreadsEnabled: Boolean,
|
||||
isMultiSelectEnabled: Boolean,
|
||||
): List<TimelineItemAction> {
|
||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||
return buildSet {
|
||||
@@ -206,9 +200,6 @@ class DefaultActionListPresenter(
|
||||
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
if (isMultiSelectEnabled && timelineItem.canBeSelected()) {
|
||||
add(TimelineItemAction.Select)
|
||||
}
|
||||
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
|
||||
if (timelineItem.content is TimelineItemEventContentWithAttachment ||
|
||||
timelineItem.content is TimelineItemGalleryContent ||
|
||||
|
||||
-1
@@ -20,7 +20,6 @@ enum class TimelineItemAction(
|
||||
) {
|
||||
ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on),
|
||||
Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward),
|
||||
Select(CommonStrings.action_select, CompoundDrawables.ic_compound_check),
|
||||
CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy),
|
||||
CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy),
|
||||
CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link),
|
||||
|
||||
-1
@@ -19,7 +19,6 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Select,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.AddCaption,
|
||||
|
||||
+27
-3
@@ -7,18 +7,42 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.compound.R
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
/** Maximum number of messages that can be selected at once. */
|
||||
const val MAX_SELECTION_COUNT = 30
|
||||
const val MAX_SELECTION_COUNT = 10
|
||||
|
||||
@Immutable
|
||||
sealed interface SelectionState {
|
||||
/** Selection mode is not active. */
|
||||
data object Disabled : SelectionState
|
||||
|
||||
/** Selection mode is active with the given selection. */
|
||||
data class Active(val selectedEventIds: ImmutableSet<EventId>) : SelectionState
|
||||
/** Selection mode is active for [action], with the given selection. */
|
||||
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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ sealed interface TimelineEvent {
|
||||
|
||||
data object MarkAllAsRead : TimelineEvent
|
||||
|
||||
data class EnterSelectionMode(val eventId: EventId) : TimelineEvent
|
||||
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
|
||||
data class ToggleSelection(val eventId: EventId) : TimelineEvent
|
||||
data object ExitSelectionMode : TimelineEvent
|
||||
|
||||
|
||||
+14
-12
@@ -34,7 +34,6 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.canBeSelected
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
@@ -265,7 +264,7 @@ class TimelinePresenter(
|
||||
}
|
||||
is TimelineEvent.EnterSelectionMode -> {
|
||||
if (!isMultiSelectEnabled) return
|
||||
selectionState = SelectionState.Active(persistentSetOf(event.eventId))
|
||||
selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId))
|
||||
}
|
||||
is TimelineEvent.ToggleSelection -> {
|
||||
val current = selectionState as? SelectionState.Active ?: return
|
||||
@@ -273,13 +272,13 @@ class TimelinePresenter(
|
||||
selectionState = when {
|
||||
event.eventId in selected -> {
|
||||
val next = (selected - event.eventId).toPersistentSet()
|
||||
if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(next)
|
||||
if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next)
|
||||
}
|
||||
selected.size >= MAX_SELECTION_COUNT -> {
|
||||
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached))
|
||||
current
|
||||
}
|
||||
else -> SelectionState.Active((selected + event.eventId).toPersistentSet())
|
||||
else -> current.copy(selectedEventIds = (selected + event.eventId).toPersistentSet())
|
||||
}
|
||||
}
|
||||
is TimelineEvent.ExitSelectionMode -> {
|
||||
@@ -394,19 +393,22 @@ class TimelinePresenter(
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
// Reconcile the selection when the items change: drop any selected event that is no longer
|
||||
// present or no longer selectable, and exit selection mode if nothing remains selected.
|
||||
LaunchedEffect(timelineItems.map { it.identifier() }) {
|
||||
val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect
|
||||
val stillSelectable = timelineItems
|
||||
val selectableEventIds = (selectionState as? SelectionState.Active)?.let { active ->
|
||||
timelineItems
|
||||
.filterIsInstance<TimelineItem.Event>()
|
||||
.filter { it.canBeSelected() }
|
||||
.filter { active.action.canApplyTo(it) }
|
||||
.mapNotNull { it.eventId }
|
||||
.toSet()
|
||||
val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet()
|
||||
}.orEmpty()
|
||||
|
||||
// Reconcile the selection when the items change: drop any selected event that is no longer
|
||||
// present or no longer selectable, and exit selection mode if nothing remains selected.
|
||||
LaunchedEffect(selectableEventIds) {
|
||||
val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect
|
||||
val reconciled = active.selectedEventIds.filter { it in selectableEventIds }.toPersistentSet()
|
||||
selectionState = when {
|
||||
reconciled.isEmpty() -> SelectionState.Disabled
|
||||
reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled)
|
||||
reconciled.size != active.selectedEventIds.size -> active.copy(selectedEventIds = reconciled)
|
||||
else -> active
|
||||
}
|
||||
}
|
||||
|
||||
+34
-3
@@ -21,6 +21,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineState(
|
||||
@@ -108,7 +110,36 @@ val TimelineState.isSelectionModeActive: Boolean
|
||||
val TimelineState.selectedCount: Int
|
||||
get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0
|
||||
|
||||
fun TimelineState.isSelected(eventId: EventId?): Boolean {
|
||||
if (eventId == null) return false
|
||||
return (selectionState as? SelectionState.Active)?.selectedEventIds?.contains(eventId) == true
|
||||
fun TimelineState.isSelected(item: TimelineItem): Boolean {
|
||||
val selection = selectionState as? SelectionState.Active ?: return false
|
||||
return when(item){
|
||||
is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event.
|
||||
*/
|
||||
fun TimelineState.canSelect(item: TimelineItem): Boolean {
|
||||
val selection = selectionState as? SelectionState.Active ?: return false
|
||||
return when(item) {
|
||||
is TimelineItem.Event -> selection.action.canApplyTo(item)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline.
|
||||
* The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on.
|
||||
*/
|
||||
fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList<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()
|
||||
}
|
||||
|
||||
+6
-6
@@ -91,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr
|
||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.canBeSelected
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
@@ -234,17 +233,18 @@ fun TimelineView(
|
||||
selectionData = TimelineItemSelectionData(
|
||||
isSelectionModeActive = isSelectionModeActive,
|
||||
progress = selectionProgress,
|
||||
isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId),
|
||||
isSelected = state.isSelected(timelineItem),
|
||||
canBeSelected = state.canSelect(timelineItem),
|
||||
),
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = ::onLinkLongClick,
|
||||
onContentClick = { event ->
|
||||
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event)
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
|
||||
},
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = { event ->
|
||||
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event)
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
|
||||
},
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
@@ -646,14 +646,14 @@ internal fun TimelineViewPreview(
|
||||
@Composable
|
||||
internal fun TimelineViewSelectionModePreview() = ElementPreview {
|
||||
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
|
||||
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { it.canBeSelected() }.eventId!!
|
||||
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { SelectionAction.Forward.canApplyTo(it) }.eventId!!
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
TimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
selectionState = SelectionState.Active(persistentSetOf(selectedEventId)),
|
||||
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)),
|
||||
),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
onUserDataClick = {},
|
||||
|
||||
+1
@@ -20,5 +20,6 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
data class TimelineItemSelectionData(
|
||||
val isSelectionModeActive: Boolean = false,
|
||||
val isSelected: Boolean = false,
|
||||
val canBeSelected: Boolean = false,
|
||||
val progress: Float = 0f,
|
||||
)
|
||||
|
||||
-34
@@ -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
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.SelectionAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
@@ -28,7 +29,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
fun SelectionModeTopBar(
|
||||
selectedCount: Int,
|
||||
action: SelectionAction,
|
||||
onCancelClick: () -> Unit,
|
||||
onConfirmClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -43,6 +46,17 @@ fun SelectionModeTopBar(
|
||||
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = onConfirmClick,
|
||||
enabled = selectedCount > 0,
|
||||
) {
|
||||
Icon(
|
||||
resourceId = action.iconRes,
|
||||
contentDescription = stringResource(action.titleRes),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +65,12 @@ fun SelectionModeTopBar(
|
||||
internal fun SelectionModeTopBarPreview(
|
||||
@PreviewParameter(SelectionCountProvider::class) count: Int,
|
||||
) = ElementPreview {
|
||||
SelectionModeTopBar(selectedCount = count, onCancelClick = {})
|
||||
SelectionModeTopBar(
|
||||
selectedCount = count,
|
||||
action = SelectionAction.Forward,
|
||||
onCancelClick = {},
|
||||
onConfirmClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
internal class SelectionCountProvider : PreviewParameterProvider<Int> {
|
||||
|
||||
+77
-7
@@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
|
||||
import io.element.android.features.messages.impl.threads.list.aThreadListItem
|
||||
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.SelectionAction
|
||||
import io.element.android.features.messages.impl.timeline.SelectionState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
@@ -76,6 +80,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
@@ -113,6 +118,7 @@ import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.element.android.tests.testutils.testWithLifecycleOwner
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -285,17 +291,37 @@ class MessagesPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action forward`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<EventId, Unit> { }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onForwardEventClickLambda = onForwardEventClickLambda,
|
||||
fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest {
|
||||
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
|
||||
val presenter = createMessagesPresenter(
|
||||
timelineEventSink = timelineEventSink,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
||||
timelineEventSink.assertions().isCalledOnce()
|
||||
.with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action forward navigates directly when multi select is disabled`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<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,
|
||||
)
|
||||
|
||||
@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(
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
timeline: Timeline = FakeTimeline(),
|
||||
@@ -1446,6 +1515,7 @@ class MessagesPresenterTest {
|
||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
timelineEventSink: (TimelineEvent) -> Unit = {},
|
||||
timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
|
||||
aMessageComposerState(
|
||||
@@ -1468,7 +1538,7 @@ class MessagesPresenterTest {
|
||||
room = joinedRoom,
|
||||
composerPresenter = messageComposerPresenter,
|
||||
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
|
||||
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
|
||||
timelinePresenter = { timelineState },
|
||||
timelineProtectionPresenter = { aTimelineProtectionState() },
|
||||
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||
linkPresenter = { aLinkState() },
|
||||
|
||||
-38
@@ -70,44 +70,6 @@ class ActionListPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Select action is present for a remote message when multi-select is enabled`() = runTest {
|
||||
val presenter = createActionListPresenter(
|
||||
isDeveloperModeEnabled = false,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvent.ComputeForMessage(
|
||||
event = aMessageEvent(),
|
||||
userEventPermissions = aUserEventPermissions(canSendMessage = true),
|
||||
)
|
||||
)
|
||||
val successState = awaitItem()
|
||||
assertThat((successState.target as ActionListState.Target.Success).actions).contains(TimelineItemAction.Select)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Select action is absent when multi-select is disabled`() = runTest {
|
||||
val presenter = createActionListPresenter(
|
||||
isDeveloperModeEnabled = false,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvent.ComputeForMessage(
|
||||
event = aMessageEvent(),
|
||||
userEventPermissions = aUserEventPermissions(canSendMessage = true),
|
||||
)
|
||||
)
|
||||
val successState = awaitItem()
|
||||
assertThat((successState.target as ActionListState.Target.Success).actions).doesNotContain(TimelineItemAction.Select)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute for message from me redacted`() = runTest {
|
||||
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
|
||||
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -50,8 +50,10 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -1835,9 +1837,9 @@ class TimelinePresenterTest {
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID)))
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
|
||||
assertThat(state.selectedCount).isEqualTo(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
@@ -1849,7 +1851,7 @@ class TimelinePresenterTest {
|
||||
val presenter = createTimelinePresenter(featureFlagService = featureFlagService)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
expectNoEvents()
|
||||
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
@@ -1867,7 +1869,7 @@ class TimelinePresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
@@ -1888,7 +1890,7 @@ class TimelinePresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
@@ -1897,6 +1899,32 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - selection drops events the action cannot apply to`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
// The message is replaced by a state event, which cannot be forwarded.
|
||||
timelineItems.value = listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
UniqueId("1"),
|
||||
anEventTimelineItem(eventId = AN_EVENT_ID, content = StateContent("", OtherState.RoomCreate)),
|
||||
)
|
||||
)
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
-47
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user