Timeline : initial multi-select state

This commit is contained in:
ganfra
2026-09-17 14:49:03 +02:00
parent 8cd3750cde
commit 51c06b4bec
24 changed files with 726 additions and 40 deletions
@@ -402,6 +402,10 @@ class MessagesPresenter(
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
TimelineItemAction.Select -> {
val eventId = targetEvent.eventId ?: return@launch
timelineState.eventSink(TimelineEvent.EnterSelectionMode(eventId))
}
}
}
@@ -10,6 +10,8 @@ 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
@@ -89,12 +91,15 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
import io.element.android.features.messages.impl.timeline.isSelectionModeActive
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.selectedCount
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.SelectionModeTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
@@ -177,6 +182,14 @@ fun MessagesView(
block()
}
// Entering selection mode collapses the composer, so dismiss the keyboard too. hideKeyboard
// triggers the IME hide animation, keeping it in sync with the composer collapse.
LaunchedEffect(state.timelineState.isSelectionModeActive) {
if (state.timelineState.isSelectionModeActive) {
localView.hideKeyboard()
}
}
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId
@@ -233,34 +246,45 @@ fun MessagesView(
Scaffold(
contentWindowInsets = scaffoldScrollableContentInsets,
topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
heroes = state.heroes,
isTombstoned = state.isTombstoned,
onBackClick = onBackClick,
)
} else {
MessagesViewTopBar(
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = {
MessagesMenuActions(
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
roomCallState = state.roomCallState,
onJoinCallClick = onJoinCallClick,
onThreadsListClick = onThreadsListClick
)
}
)
when {
state.timelineState.isSelectionModeActive -> {
SelectionModeTopBar(
selectedCount = state.timelineState.selectedCount,
onCancelClick = {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
},
)
}
state.timelineState.timelineMode is Timeline.Mode.Thread -> {
ThreadTopBar(
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
heroes = state.heroes,
isTombstoned = state.isTombstoned,
onBackClick = onBackClick,
)
}
else -> {
MessagesViewTopBar(
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = {
MessagesMenuActions(
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
roomCallState = state.roomCallState,
onJoinCallClick = onJoinCallClick,
onThreadsListClick = onThreadsListClick
)
}
)
}
}
},
content = { padding ->
@@ -27,6 +27,7 @@ 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
@@ -109,6 +110,7 @@ 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) {
@@ -120,6 +122,7 @@ class DefaultActionListPresenter(
pinnedEventIds = pinnedEventIds,
target = target,
isThreadsEnabled = isThreadsEnabled.value,
isMultiSelectEnabled = isMultiSelectEnabled.value,
)
}
}
@@ -137,6 +140,7 @@ class DefaultActionListPresenter(
pinnedEventIds: ImmutableList<EventId>,
target: MutableState<ActionListState.Target>,
isThreadsEnabled: Boolean,
isMultiSelectEnabled: Boolean,
) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
@@ -146,6 +150,7 @@ class DefaultActionListPresenter(
isDeveloperModeEnabled = isDeveloperModeEnabled,
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
isThreadsEnabled = isThreadsEnabled,
isMultiSelectEnabled = isMultiSelectEnabled,
)
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
@@ -179,6 +184,7 @@ class DefaultActionListPresenter(
isDeveloperModeEnabled: Boolean,
isEventPinned: Boolean,
isThreadsEnabled: Boolean,
isMultiSelectEnabled: Boolean,
): List<TimelineItemAction> {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildSet {
@@ -200,6 +206,9 @@ 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 ||
@@ -20,6 +20,7 @@ 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),
@@ -19,6 +19,7 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Select,
TimelineItemAction.Edit,
TimelineItemAction.EditPoll,
TimelineItemAction.AddCaption,
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.link.LinkEvent
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@@ -291,6 +292,7 @@ private fun PinnedMessagesListLoaded(
onContentLayoutChange = onContentLayoutChange
)
},
selectionData = TimelineItemSelectionData(),
)
}
}
@@ -0,0 +1,24 @@
/*
* 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 androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.ImmutableSet
/** Maximum number of messages that can be selected at once. */
const val MAX_SELECTION_COUNT = 30
@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
}
@@ -29,6 +29,10 @@ sealed interface TimelineEvent {
data object MarkAllAsRead : TimelineEvent
data class EnterSelectionMode(val eventId: EventId) : TimelineEvent
data class ToggleSelection(val eventId: EventId) : TimelineEvent
data object ExitSelectionMode : TimelineEvent
/**
* Events coming from a timeline item.
*/
@@ -25,6 +25,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
@@ -33,6 +34,7 @@ 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
@@ -46,6 +48,8 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -68,7 +72,9 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
@@ -100,6 +106,7 @@ class TimelinePresenter(
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
@@ -164,6 +171,18 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
val isMultiSelectEnabled by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it }
}
var selectionState by remember { mutableStateOf<SelectionState>(SelectionState.Disabled) }
// When the feature flag is turned off, clear any active selection.
LaunchedEffect(isMultiSelectEnabled) {
if (!isMultiSelectEnabled) {
selectionState = SelectionState.Disabled
}
}
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) {
@@ -244,6 +263,28 @@ class TimelinePresenter(
is TimelineEvent.JumpToLive -> {
timelineController.focusOnLive()
}
is TimelineEvent.EnterSelectionMode -> {
if (!isMultiSelectEnabled) return
selectionState = SelectionState.Active(persistentSetOf(event.eventId))
}
is TimelineEvent.ToggleSelection -> {
val current = selectionState as? SelectionState.Active ?: return
val selected = current.selectedEventIds
selectionState = when {
event.eventId in selected -> {
val next = (selected - event.eventId).toPersistentSet()
if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(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())
}
}
is TimelineEvent.ExitSelectionMode -> {
selectionState = SelectionState.Disabled
}
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -353,6 +394,23 @@ 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
.filterIsInstance<TimelineItem.Event>()
.filter { it.canBeSelected() }
.mapNotNull { it.eventId }
.toSet()
val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet()
selectionState = when {
reconciled.isEmpty() -> SelectionState.Disabled
reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled)
else -> active
}
}
// Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
// read marker advances in place — the SDK swaps the marker virtual item to a new position
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
@@ -463,6 +521,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
selectionState = selectionState,
eventSink = ::handleEvent,
)
}
@@ -37,6 +37,7 @@ data class TimelineState(
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val selectionState: SelectionState,
val eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -100,3 +101,14 @@ sealed interface JumpToUnreadState {
/** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
}
val TimelineState.isSelectionModeActive: Boolean
get() = selectionState is SelectionState.Active
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
}
@@ -63,6 +63,7 @@ fun aTimelineState(
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
selectionState: SelectionState = SelectionState.Disabled,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -84,6 +85,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
selectionState = selectionState,
eventSink = eventSink,
)
}
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -83,14 +84,17 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
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
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard
@@ -111,6 +115,7 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@@ -189,6 +194,18 @@ fun TimelineView(
state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
}
val isSelectionModeActive = state.isSelectionModeActive
// Animate once here (shared by every row) rather than per-row: the value is identical for all
// rows since it only depends on the timeline-wide selection mode.
val selectionProgress by animateFloatAsState(
targetValue = if (isSelectionModeActive) 1f else 0f,
label = "selectionProgress",
)
fun onToggleSelection(event: TimelineItem.Event) {
val eventId = event.eventId ?: return
state.eventSink(TimelineEvent.ToggleSelection(eventId))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
@@ -214,12 +231,21 @@ fun TimelineView(
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
selectionData = TimelineItemSelectionData(
isSelectionModeActive = isSelectionModeActive,
progress = selectionProgress,
isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId),
),
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onContentClick = { event ->
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event)
},
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
onLongClick = { event ->
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event)
},
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@@ -616,6 +642,36 @@ internal fun TimelineViewPreview(
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewSelectionModePreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { it.canBeSelected() }.eventId!!
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = timelineItems,
selectionState = SelectionState.Active(persistentSetOf(selectedEventId)),
),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onContentClick = {},
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
}
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
// here only varies the scroll-target state, not the upper indicator's visibility.
@@ -0,0 +1,54 @@
/*
* 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.components
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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
/**
* Compact selection indicator used in the timeline selection mode, in place of a Material
* [androidx.compose.material3.Checkbox] (which reserves a 48dp interactive area). It is purely
* visual: the tap is handled by the whole-row click target, so this only reflects [selected].
*/
@Composable
internal fun SelectionIndicator(
selected: Boolean,
modifier: Modifier = Modifier,
) {
Icon(
modifier = modifier,
imageVector = if (selected) CompoundIcons.CheckCircleSolid() else CompoundIcons.Circle(),
contentDescription = null,
tint = if (selected) ElementTheme.colors.iconAccentPrimary else ElementTheme.colors.iconTertiary,
)
}
@PreviewsDayNight
@Composable
internal fun SelectionIndicatorPreview() = ElementPreview {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Circle(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
@PreviewsDayNight
@Composable
internal fun SelectionIndicatorSelectedPreview() = ElementPreview {
SelectionIndicator(selected = true)
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -59,6 +60,7 @@ import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
@@ -180,6 +182,9 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
// Horizontal offset applied to this row's content by selection mode. The read receipts are
// anchored to the end edge and should stay pinned, so they are counter-offset by this amount.
selectionContentOffset: Dp = 0.dp,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
// Only pass down a custom clickable lambda if the content can be clicked separately
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
@@ -326,7 +331,10 @@ fun TimelineItemEventRow(
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp)
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
modifier = Modifier
.padding(top = 4.dp)
.offset(x = -selectionContentOffset)
)
}
}
@@ -190,6 +190,7 @@ private fun TimelineItemGroupedEventsRowContent(
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,
selectionData = TimelineItemSelectionData(),
)
}
}
@@ -8,22 +8,30 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent
@@ -31,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
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.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -60,6 +69,7 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
selectionData: TimelineItemSelectionData,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -92,17 +102,130 @@ internal fun TimelineItemRow(
)
},
) {
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
14.dp
} else {
2.dp
val isSelectionModeActive = selectionData.isSelectionModeActive
val selectionProgress = selectionData.progress
val isSelected = selectionData.isSelected
val backgroundModifier = when {
isSelected -> {
Modifier.background(ElementTheme.colors.bgAccentSelected)
}
timelineItem.isEvent(focusedEventId) -> {
val focusedEventOffset =
if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
14.dp
} else {
2.dp
}
Modifier.focusedEvent(focusedEventOffset)
}
else -> {
Modifier
}
Modifier.focusedEvent(focusedEventOffset)
} else {
Modifier
}
Box(modifier = modifier.then(backgroundModifier)) {
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { it.canBeSelected() }
// Only start-aligned message bubbles slide to make room for the selection indicator:
// - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room.
// - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put.
val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true
val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing
val layoutDirection = LocalLayoutDirection.current
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
Box(
modifier = modifier.then(backgroundModifier)
) {
TimelineItemRowContent(
modifier = Modifier
.fillMaxWidth()
.offset(x = contentSlide),
selectionContentOffset = contentSlide,
timelineItem = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
onSwipeToReply = onSwipeToReply,
eventSink = eventSink,
eventContentView = eventContentView,
)
if (selectableEvent != null && selectionProgress > 0f) {
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
SelectionIndicator(
selected = isSelected,
modifier = Modifier
.align(Alignment.CenterStart)
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
.padding(start = 12.dp)
.size(24.dp),
)
}
if (isSelectionModeActive && selectableEvent != null) {
// In selection mode the whole row toggles selection.
Box(
modifier = Modifier
.matchParentSize()
.clickable { onContentClick(selectableEvent) }
)
}
}
}
/** Width reserved for the selection indicator; the row content slides by this amount. */
private val SELECTION_COLUMN_WIDTH = 36.dp
/**
* Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered
* tile (state event, legacy call invite, call/RTC notification, day divider, grouped events).
* Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection
* indicator; centered tiles stay put.
*/
private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event &&
content !is TimelineItemStateContent &&
content !is TimelineItemLegacyCallInviteContent &&
content !is TimelineItemRtcNotificationContent
@Suppress("LongMethod")
@Composable
private fun TimelineItemRowContent(
timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
modifier: Modifier = Modifier,
selectionContentOffset: Dp = 0.dp,
) {
Box(modifier) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
@@ -197,6 +320,7 @@ internal fun TimelineItemRow(
onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink,
selectionContentOffset = selectionContentOffset,
eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
},
@@ -0,0 +1,24 @@
/*
* 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.components
/**
* Row-scoped selection state passed to [TimelineItemRow].
*
* @param isSelectionModeActive whether the timeline is currently in selection mode. Drives
* behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress].
* @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in).
* Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive]
* during the enter/exit animation.
* @param isSelected whether this specific row's event is part of the current selection.
*/
data class TimelineItemSelectionData(
val isSelectionModeActive: Boolean = false,
val isSelected: Boolean = false,
val progress: Float = 0f,
)
@@ -0,0 +1,34 @@
/*
* 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
}
}
@@ -0,0 +1,59 @@
/*
* 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.topbars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.preview.ElementPreview
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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionModeTopBar(
selectedCount: Int,
onCancelClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
Text(text = pluralStringResource(R.plurals.screen_room_timeline_selection_title, selectedCount, selectedCount))
},
navigationIcon = {
IconButton(
onClick = onCancelClick,
) {
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close))
}
},
)
}
@PreviewsDayNight
@Composable
internal fun SelectionModeTopBarPreview(
@PreviewParameter(SelectionCountProvider::class) count: Int,
) = ElementPreview {
SelectionModeTopBar(selectedCount = count, onCancelClick = {})
}
internal class SelectionCountProvider : PreviewParameterProvider<Int> {
override val values = sequenceOf(0, 1, 5)
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<resources>
<!-- %1$d is the number of selected messages -->
<plurals name="screen_room_timeline_selection_title">
<item quantity="one">%1$d selected</item>
<item quantity="other">%1$d selected</item>
</plurals>
<string name="screen_room_timeline_selection_limit_reached">You can select up to 30 messages</string>
</resources>
@@ -70,6 +70,44 @@ 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)
@@ -34,6 +34,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
@@ -86,6 +87,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
@@ -1821,6 +1823,80 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - entering selection mode selects the target event`() = 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()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID)))
assertThat(state.selectedCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - entering selection mode is ignored when the flag is off`() = runTest {
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false))
val presenter = createTimelinePresenter(featureFlagService = featureFlagService)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
expectNoEvents()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggling the last selected event exits selection mode`() = 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(AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - turning the flag off clears the active selection`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true))
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = featureFlagService,
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
@@ -1911,6 +1987,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead,
@@ -0,0 +1,47 @@
/*
* 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()
}
}