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.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))
}
} }
} }
@@ -10,6 +10,8 @@ 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
@@ -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.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.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition 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.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar 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.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
@@ -177,6 +182,14 @@ fun MessagesView(
block() 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) { fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}") Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId val eventId = event.eventId
@@ -233,34 +246,45 @@ fun MessagesView(
Scaffold( Scaffold(
contentWindowInsets = scaffoldScrollableContentInsets, contentWindowInsets = scaffoldScrollableContentInsets,
topBar = { topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) { when {
ThreadTopBar( state.timelineState.isSelectionModeActive -> {
roomName = state.roomName, SelectionModeTopBar(
roomAvatarData = state.roomAvatar, selectedCount = state.timelineState.selectedCount,
heroes = state.heroes, onCancelClick = {
isTombstoned = state.isTombstoned, state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
onBackClick = onBackClick, },
) )
} else { }
MessagesViewTopBar( state.timelineState.timelineMode is Timeline.Mode.Thread -> {
roomName = state.roomName, ThreadTopBar(
roomAvatar = state.roomAvatar, roomName = state.roomName,
isTombstoned = state.isTombstoned, roomAvatarData = state.roomAvatar,
heroes = state.heroes, heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState, isTombstoned = state.isTombstoned,
sharedHistoryIcon = state.topBarSharedHistoryIcon, onBackClick = onBackClick,
dmUserStatus = state.dmUserStatus, )
onBackClick = { hidingKeyboard { onBackClick() } }, }
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, else -> {
menuActions = { MessagesViewTopBar(
MessagesMenuActions( roomName = state.roomName,
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, roomAvatar = state.roomAvatar,
roomCallState = state.roomCallState, isTombstoned = state.isTombstoned,
onJoinCallClick = onJoinCallClick, heroes = state.heroes,
onThreadsListClick = onThreadsListClick 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 -> 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.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
@@ -109,6 +110,7 @@ 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) {
@@ -120,6 +122,7 @@ class DefaultActionListPresenter(
pinnedEventIds = pinnedEventIds, pinnedEventIds = pinnedEventIds,
target = target, target = target,
isThreadsEnabled = isThreadsEnabled.value, isThreadsEnabled = isThreadsEnabled.value,
isMultiSelectEnabled = isMultiSelectEnabled.value,
) )
} }
} }
@@ -137,6 +140,7 @@ 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)
@@ -146,6 +150,7 @@ 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)
@@ -179,6 +184,7 @@ 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 {
@@ -200,6 +206,9 @@ 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 ||
@@ -20,6 +20,7 @@ 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),
@@ -19,6 +19,7 @@ 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,
@@ -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.link.LinkView
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.components.TimelineItemRow 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.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@@ -291,6 +292,7 @@ private fun PinnedMessagesListLoaded(
onContentLayoutChange = onContentLayoutChange 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 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. * Events coming from a timeline item.
*/ */
@@ -25,6 +25,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.messages.impl.MessagesNavigator 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.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState 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.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
@@ -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.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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 io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -100,6 +106,7 @@ class TimelinePresenter(
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>, private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
@@ -164,6 +171,18 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) 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() val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
@@ -244,6 +263,28 @@ class TimelinePresenter(
is TimelineEvent.JumpToLive -> { is TimelineEvent.JumpToLive -> {
timelineController.focusOnLive() 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.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch { TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -353,6 +394,23 @@ 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
// 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 // Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
// read marker advances in place — the SDK swaps the marker virtual item to a new position // read marker advances in place — the SDK swaps the marker virtual item to a new position
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the // without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
@@ -463,6 +521,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value, jumpToUnread = jumpToUnread.value,
selectionState = selectionState,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -37,6 +37,7 @@ data class TimelineState(
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean, val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState, val jumpToUnread: JumpToUnreadState,
val selectionState: SelectionState,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event 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. */ /** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState 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, displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None, newEventState: NewEventState = NewEventState.None,
selectionState: SelectionState = SelectionState.Disabled,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -84,6 +85,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread, jumpToUnread = jumpToUnread,
selectionState = selectionState,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay 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.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.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories 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.di.aFakeTimelineItemPresenterFactories
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.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard 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.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -189,6 +194,18 @@ fun TimelineView(
state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
@@ -214,12 +231,21 @@ fun TimelineView(
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId, focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries, displayThreadSummaries = state.displayThreadSummaries,
selectionData = TimelineItemSelectionData(
isSelectionModeActive = isSelectionModeActive,
progress = selectionProgress,
isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId),
),
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick, onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick, onContentClick = { event ->
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event)
},
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick, onLongClick = { event ->
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event)
},
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, 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 // 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` // 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. // 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.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -180,6 +182,9 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, 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 -> eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
// Only pass down a custom clickable lambda if the content can be clicked separately // Only pass down a custom clickable lambda if the content can be clicked separately
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
@@ -326,7 +331,10 @@ fun TimelineItemEventRow(
receipts = event.readReceiptState.receipts, receipts = event.readReceiptState.receipts,
), ),
onReadReceiptsClick = { onReadReceiptClick(event) }, 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 = {}, onSwipeToReply = {},
eventSink = eventSink, eventSink = eventSink,
eventContentView = eventContentView, eventContentView = eventContentView,
selectionData = TimelineItemSelectionData(),
) )
} }
} }
@@ -8,22 +8,30 @@
package io.element.android.features.messages.impl.timeline.components 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.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush 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.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData 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.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.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -60,6 +69,7 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
displayThreadSummaries: Boolean, displayThreadSummaries: Boolean,
selectionData: TimelineItemSelectionData,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -92,17 +102,130 @@ internal fun TimelineItemRow(
) )
}, },
) { ) {
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { val isSelectionModeActive = selectionData.isSelectionModeActive
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { val selectionProgress = selectionData.progress
14.dp val isSelected = selectionData.isSelected
} else { val backgroundModifier = when {
2.dp 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) { when (timelineItem) {
is TimelineItem.Virtual -> { is TimelineItem.Virtual -> {
TimelineItemVirtualRow( TimelineItemVirtualRow(
@@ -197,6 +320,7 @@ internal fun TimelineItemRow(
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink, eventSink = eventSink,
selectionContentOffset = selectionContentOffset,
eventContentView = { contentModifier, onContentLayoutChange -> eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, 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 @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)
@@ -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.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.Presenter 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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId 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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
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.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay 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 { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem() return awaitItem()
} }
@@ -1911,6 +1987,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead, 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()
}
}
@@ -161,4 +161,11 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = false, isFinished = false,
), ),
MessageMultiSelect(
key = "feature.message_multi_select",
title = "Multi-select messages",
description = "Adds a Select action to the message menu to pick several messages at once.",
defaultValue = { false },
isFinished = false,
),
} }