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