Merge pull request #7864 from element-hq/feature/fga/timeline-multi-forward

Initial implementation of timeline multi forward
This commit is contained in:
ganfra
2026-10-08 18:45:35 +02:00
committed by GitHub
67 changed files with 1295 additions and 155 deletions
@@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode(
} else {
(graph as TimelineBindings).timelineProvider
}
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
@@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
}
data class Params(
val eventId: EventId,
val eventIds: List<EventId>,
val timelineProvider: TimelineProvider,
) : NodeInputs
@@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint {
buildContext = buildContext,
plugins = listOf(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
eventIds = params.eventIds,
timelineProvider = params.timelineProvider,
),
callback,
@@ -54,13 +54,13 @@ class ForwardMessagesNode(
object NavTarget : Parcelable
data class Inputs(
val eventId: EventId,
val eventIds: List<EventId>,
val timelineProvider: TimelineProvider,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val callback: ForwardEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
@@ -28,22 +28,20 @@ import timber.log.Timber
@AssistedInject
class ForwardMessagesPresenter(
@Assisted eventId: String,
@Assisted private val eventIds: List<EventId>,
@Assisted private val timelineProvider: TimelineProvider,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
fun interface Factory {
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
fun create(eventIds: List<EventId>, timelineProvider: TimelineProvider): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.forwardEvent(eventId, roomIds)
sessionCoroutineScope.forwardEvents(eventIds, roomIds)
}
@Composable
@@ -60,14 +58,14 @@ class ForwardMessagesPresenter(
)
}
private fun CoroutineScope.forwardEvent(
eventId: EventId,
private fun CoroutineScope.forwardEvents(
eventIds: List<EventId>,
roomIds: List<RoomId>,
) = launch {
suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds)
timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds)
.onFailure {
Timber.e(it, "Error while forwarding event")
Timber.e(it, "Error while forwarding events")
}
.getOrThrow()
roomIds
@@ -47,7 +47,7 @@ class DefaultForwardEntryPointTest {
override fun onDone(roomIds: List<RoomId>) = lambdaError()
}
val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID,
eventIds = listOf(AN_EVENT_ID),
timelineProvider = FakeTimelineProvider(),
)
val result = entryPoint.createNode(
@@ -59,7 +59,7 @@ class DefaultForwardEntryPointTest {
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
assertThat(result.plugins).contains(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
eventIds = params.eventIds,
timelineProvider = params.timelineProvider,
)
)
@@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest {
@Test
fun `present - forward successful`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
this.forwardEventsLambda = forwardEventsLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest {
assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
forwardEventLambda.assertions().isCalledOnce()
forwardEventsLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.failure<Unit>(IllegalStateException("error"))
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
this.forwardEventsLambda = forwardEventsLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest {
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce()
forwardEventsLambda.assertions().isCalledOnce()
}
}
}
fun TestScope.createForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
eventIds: List<EventId> = listOf(AN_EVENT_ID),
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter(
eventId = eventId.value,
eventIds = eventIds,
timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this,
)
@@ -23,6 +23,7 @@ sealed interface MessagesEvent {
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
data object ConfirmSelectionAction : MessagesEvent
}
enum class InviteDialogAction {
@@ -181,7 +181,7 @@ class MessagesFlowNode(
@Parcelize
data class ForwardEvent(
val eventId: EventId,
val eventIds: List<EventId>,
@IgnoredOnParcel
val timelineProvider: TimelineProvider? = null,
) : NavTarget
@@ -306,8 +306,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -464,7 +464,7 @@ class MessagesFlowNode(
is NavTarget.ForwardEvent -> {
// If no timeline provider is received, assume the live timeline should be used
val timelineProvider = navTarget.timelineProvider ?: timelineController
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
@@ -551,7 +551,7 @@ class MessagesFlowNode(
}
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider))
backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), timelineProvider = timelineProvider))
}
override fun navigateToThread(threadRootId: ThreadId) {
@@ -617,8 +617,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
override fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList
interface MessagesNavigator {
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToEditPoll(eventId: EventId)
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
@@ -138,7 +138,7 @@ class MessagesNode(
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation()
fun navigateToCreatePoll()
@@ -221,8 +221,8 @@ class MessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
callback.forwardEvent(eventId, timelineProvider)
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
callback.forwardEvents(eventIds, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -38,6 +38,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.SelectionAction
import io.element.android.features.messages.impl.timeline.SelectionState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -52,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
@@ -257,6 +260,17 @@ class MessagesPresenter(
redactEventAction = redactEventAction,
)
}
is MessagesEvent.ConfirmSelectionAction -> {
val selection = timelineState.selectionState as? SelectionState.Active
if (selection != null) {
when (selection.action) {
SelectionAction.Forward -> {
navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder(), timelineController)
}
}
timelineState.eventSink(TimelineEvent.ExitSelectionMode)
}
}
is MessagesEvent.ConfirmRedact -> {
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
redactEventAction.value = AsyncAction.Uninitialized
@@ -413,7 +427,14 @@ class MessagesPresenter(
}
}
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.Forward -> {
val eventId = targetEvent.eventId ?: return@launch
if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) {
timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId))
} else {
navigator.forwardEvents(listOf(eventId), timelineController)
}
}
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
TimelineItemAction.Pin -> handlePinAction(targetEvent)
@@ -612,11 +633,6 @@ class MessagesPresenter(
navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo)
}
private fun handleForwardAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController)
}
private fun handleReportAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.navigateToReportMessage(event.eventId, event.senderId)
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -77,24 +78,31 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.SelectionAction
import io.element.android.features.messages.impl.timeline.SelectionState
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.canApplyTo
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
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
@@ -134,6 +142,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@@ -178,6 +187,18 @@ 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()
}
}
BackHandler(enabled = state.timelineState.isSelectionModeActive) {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
}
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId
@@ -234,34 +255,50 @@ 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 -> {
val selection = state.timelineState.selectionState as SelectionState.Active
SelectionModeTopBar(
selectedCount = state.timelineState.selectedCount,
action = selection.action,
onCancelClick = {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
},
onConfirmClick = {
state.eventSink(MessagesEvent.ConfirmSelectionAction)
},
)
}
state.timelineState.timelineMode is Timeline.Mode.Thread -> {
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 ->
@@ -728,6 +765,40 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStatePreviewParam::cl
)
}
@PreviewsDayNight
@Composable
internal fun MessagesViewSelectionModePreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
val selectedEventIds = timelineItems
.filterIsInstance<TimelineItem.Event>()
.filter { SelectionAction.Forward.canApplyTo(it) }
.take(2)
.mapNotNull { it.eventId }
.toImmutableSet()
MessagesView(
state = aMessagesState(
timelineState = aTimelineState(
timelineItems = timelineItems,
selectionState = SelectionState.Active(SelectionAction.Forward, selectedEventIds),
),
),
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@Preview
@Composable
internal fun MessagesViewA11yPreview() = ElementPreview {
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
@@ -289,6 +290,7 @@ private fun PinnedMessagesListLoaded(
onContentLayoutChange = onContentLayoutChange
)
},
selectionData = TimelineItemSelectionData(),
)
}
}
@@ -143,7 +143,7 @@ class ThreadedMessagesNode(
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider)
fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation()
fun navigateToCreatePoll()
@@ -227,8 +227,8 @@ class ThreadedMessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
callback.handleForwardEventClick(eventId, timelineProvider)
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
callback.handleForwardEventClick(eventIds, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Element Creations 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.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import io.element.android.compound.R
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
/** Maximum number of messages that can be selected at once. */
const val MAX_SELECTION_COUNT = 10
@Immutable
sealed interface SelectionState {
/** Selection mode is not active. */
data object Disabled : SelectionState
/**
* Selection mode is active for [action], with the given selection.
* [selectedEventIds] can be empty: selection mode is never exited automatically, only when the user explicitly leaves it.
*/
data class Active(
val action: SelectionAction,
val selectedEventIds: ImmutableSet<EventId>,
) : SelectionState
}
/**
* An action which can be performed on several timeline events at once, and which selection mode can be entered for.
*/
enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRes val iconRes: Int) {
Forward(CommonStrings.action_forward, R.drawable.ic_compound_forward),
}
/**
* Whether [event] can be part of a selection made for this action.
* Each action uses the same criteria as the single event action it mirrors.
*/
fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) {
SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded()
}
/**
* This selection without [eventIds].
* Selection mode stays active even when nothing remains selected, see [SelectionState.Active].
*/
fun SelectionState.deselect(eventIds: Set<EventId>): SelectionState = when (this) {
SelectionState.Disabled -> this
is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toImmutableSet())
}
/**
* The ids of the selected events which [SelectionState.Active.action] cannot be applied to anymore, for instance because the message has been
* deleted. They are meant to be given to [deselect].
*/
fun SelectionState.eventIdsToDeselect(timelineItems: List<TimelineItem>): Set<EventId> {
val selection = this as? SelectionState.Active ?: return emptySet()
if (selection.selectedEventIds.isEmpty()) return emptySet()
val result = mutableSetOf<EventId>()
fun process(event: TimelineItem.Event) {
val eventId = event.eventId ?: return
if (eventId in selection.selectedEventIds && !selection.action.canApplyTo(event)) {
result.add(eventId)
}
}
timelineItems.forEach { item ->
when (item) {
is TimelineItem.Event -> process(item)
// A deleted message can be folded into a group of deleted messages, so look inside the groups too.
is TimelineItem.GroupedEvents -> item.events.forEach(::process)
is TimelineItem.Virtual -> Unit
}
}
return result
}
@@ -28,7 +28,9 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
data class ToggleSelection(val eventId: EventId) : TimelineEvent
data object ExitSelectionMode : TimelineEvent
data class CopyToClipboard(val text: String) : TimelineEvent
/**
@@ -48,6 +48,8 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
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
@@ -71,13 +73,17 @@ 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.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -103,6 +109,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,
@@ -172,6 +179,18 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)
}
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) {
@@ -252,6 +271,26 @@ class TimelinePresenter(
is TimelineEvent.JumpToLive -> {
timelineController.focusOnLive()
}
is TimelineEvent.EnterSelectionMode -> {
if (!isMultiSelectEnabled) return
selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId))
}
is TimelineEvent.ToggleSelection -> {
val current = selectionState as? SelectionState.Active ?: return
val selected = current.selectedEventIds
selectionState = when {
// Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS.
event.eventId in selected -> current.deselect(setOf(event.eventId))
selected.size >= MAX_SELECTION_COUNT -> {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected))
current
}
else -> current.copy(selectedEventIds = (selected + event.eventId).toImmutableSet())
}
}
is TimelineEvent.ExitSelectionMode -> {
selectionState = SelectionState.Disabled
}
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -341,6 +380,14 @@ class TimelinePresenter(
}
.launchIn(this)
// Drop the selected events which the action cannot be applied to anymore, for instance because the message has been deleted.
timelineItemsFactory.timelineItems
.map { items -> selectionState.eventIdsToDeselect(items) }
.filter { eventIdsToDeselect -> eventIdsToDeselect.isNotEmpty() }
.flowOn(dispatchers.computation)
.onEach { eventIdsToDeselect -> selectionState = selectionState.deselect(eventIdsToDeselect) }
.launchIn(this)
combine(
timelineController.timelineItems(),
room.membersStateFlow,
@@ -477,6 +524,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
selectionState = selectionState,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = ::handleEvent,
)
@@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration
data class TimelineState(
@@ -38,6 +40,7 @@ data class TimelineState(
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val selectionState: SelectionState,
// When true, formatted message bodies are rendered with the native Compose renderer.
val useNewTimelineEventRenderer: Boolean,
val eventSink: (TimelineEvent) -> Unit,
@@ -104,3 +107,43 @@ 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(item: TimelineItem): Boolean {
val selection = selectionState as? SelectionState.Active ?: return false
return when (item) {
is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId)
else -> false
}
}
/**
* Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event.
*/
fun TimelineState.canSelect(item: TimelineItem): Boolean {
val selection = selectionState as? SelectionState.Active ?: return false
return when (item) {
is TimelineItem.Event -> selection.action.canApplyTo(item)
else -> false
}
}
/**
* The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline.
* The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on.
*/
fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList<EventId> {
val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf()
return timelineItems
.filterIsInstance<TimelineItem.Event>()
.mapNotNull { it.eventId }
.filter { it in selectedEventIds }
// Most recent item is first in the timeline, so reverse the result.
.reversed()
.toImmutableList()
}
@@ -63,6 +63,7 @@ fun aTimelineState(
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
selectionState: SelectionState = SelectionState.Disabled,
useNewTimelineEventRenderer: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
@@ -85,6 +86,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
selectionState = selectionState,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
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
@@ -88,8 +89,10 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
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.designsystem.atomic.atoms.UnreadIndicatorAtom
@@ -108,6 +111,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
@@ -181,6 +185,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()) {
val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
@@ -213,12 +229,22 @@ fun TimelineView(
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
selectionData = TimelineItemSelectionData(
isSelectionModeActive = isSelectionModeActive,
progress = selectionProgress,
isSelected = state.isSelected(timelineItem),
canBeSelected = state.canSelect(timelineItem),
),
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onContentClick = { event ->
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
},
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
onLongClick = { event ->
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
},
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@@ -617,6 +643,36 @@ internal fun TimelineViewPreview(
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewSelectionModePreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { SelectionAction.Forward.canApplyTo(it) }.eventId!!
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = timelineItems,
selectionState = SelectionState.Active(SelectionAction.Forward, 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.
@@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.layout.HorizontalAlignmentLine
import androidx.compose.ui.layout.layout
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -53,12 +55,20 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer
import kotlin.math.min
private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
private val MIN_BUBBLE_WIDTH = 80.dp
/**
* Alignment line published by [MessageEventBubble] at its vertical center.
* It lets the timeline row align decorations with the bubble itself, instead of with the whole row, whose height also
* covers the sender information, the reactions, the thread summary and the read receipts.
*/
val BubbleVerticalCenter = HorizontalAlignmentLine(::min)
@Composable
fun MessageEventBubble(
state: BubbleState,
@@ -93,6 +103,12 @@ fun MessageEventBubble(
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints(
modifier = modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height, mapOf(BubbleVerticalCenter to placeable.height / 2)) {
placeable.place(0, 0)
}
}
.drawWithCache {
// Calculate the outline of the background and cache it
val outline = bubbleShape.createOutline(size, layoutDirection, this)
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 Element Creations 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.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
/**
* Selection indicator used in the timeline selection mode. It renders the Compound [Checkbox], with the minimum
* interactive size enforcement disabled so that it fits the compact selection column. 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,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Checkbox(
modifier = modifier,
checked = selected,
onCheckedChange = null,
)
}
}
@PreviewsDayNight
@Composable
internal fun SelectionIndicatorPreview() = ElementPreview {
SelectionIndicator(selected = false)
}
@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)
)
}
}
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
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.designsystem.preview.ElementPreview
@@ -190,6 +191,7 @@ private fun TimelineItemGroupedEventsRowContent(
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,
selectionData = TimelineItemSelectionData(),
)
}
}
@@ -8,28 +8,39 @@
package io.element.android.features.messages.impl.timeline.components
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.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.layout.AlignmentLine
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
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
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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
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 +71,7 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
selectionData: TimelineItemSelectionData,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -92,17 +104,148 @@ 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 {
timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> {
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 { selectionData.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
Layout(
modifier = modifier.then(backgroundModifier),
contents = listOf(
{
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
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
.padding(start = 16.dp)
.size(24.dp),
)
}
},
{
if (isSelectionModeActive) {
// In selection mode the whole row toggles selection.
Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) {
if (selectableEvent != null) {
onContentClick(selectableEvent)
}
})
}
},
),
) { (contentMeasurables, indicatorMeasurables, overlayMeasurables), constraints ->
val contentPlaceable = contentMeasurables.first().measure(constraints)
val width = contentPlaceable.width
val height = contentPlaceable.height
val indicatorPlaceable = indicatorMeasurables.firstOrNull()?.measure(constraints.copy(minWidth = 0, minHeight = 0))
val overlayPlaceable = overlayMeasurables.firstOrNull()?.measure(Constraints.fixed(width, height))
layout(width = width, height = height) {
contentPlaceable.place(0, 0)
if (indicatorPlaceable != null) {
val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: height / 2
val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width
indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2)
}
overlayPlaceable?.place(0, 0)
}
}
}
/** 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,
modifier: Modifier = Modifier,
selectionContentOffset: Dp = 0.dp,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
) {
Box(modifier) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
@@ -197,6 +340,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,28 @@
/*
* Copyright (c) 2026 Element Creations 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
/**
* Row-scoped selection state passed to [io.element.android.features.messages.impl.timeline.components.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 isSelected whether this specific row's event is part of the current selection.
* @param canBeSelected whether this specific row's event can be selected for the action selection mode was entered for.
* Rows which cannot be selected render no indicator and ignore clicks, but their content still slides: sliding only depends on the row being a
* start-aligned bubble, so that the bubbles stay aligned with each other.
* @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.
*/
data class TimelineItemSelectionData(
val isSelectionModeActive: Boolean = false,
val isSelected: Boolean = false,
val canBeSelected: Boolean = false,
val progress: Float = 0f,
)
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Element Creations 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.timeline.SelectionAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
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.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionModeTopBar(
selectedCount: Int,
action: SelectionAction,
onCancelClick: () -> Unit,
onConfirmClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
Text(text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount))
},
navigationIcon = {
IconButton(
onClick = onCancelClick,
) {
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close))
}
},
actions = {
IconButton(
onClick = onConfirmClick,
enabled = selectedCount > 0,
) {
Icon(
resourceId = action.iconRes,
contentDescription = stringResource(action.titleRes),
)
}
},
)
}
@PreviewsDayNight
@Composable
internal fun SelectionModeTopBarPreview(
@PreviewParameter(SelectionCountPreviewParam::class) count: Int,
) = ElementPreview {
SelectionModeTopBar(
selectedCount = count,
action = SelectionAction.Forward,
onCancelClick = {},
onConfirmClick = {},
)
}
internal class SelectionCountPreviewParam : PreviewParameterProvider<Int> {
override val values = sequenceOf(0, 1, 5)
}
@@ -20,7 +20,7 @@ import kotlinx.collections.immutable.ImmutableList
class FakeMessagesNavigator(
private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
private val onForwardEventClickLambda: (eventId: EventId, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
private val onForwardEventClickLambda: (eventIds: List<EventId>, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
@@ -35,8 +35,8 @@ class FakeMessagesNavigator(
onShowEventDebugInfoClickLambda(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
onForwardEventClickLambda(eventId, timelineProvider)
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
onForwardEventClickLambda(eventIds, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
import io.element.android.features.messages.impl.threads.list.aThreadListItem
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.SelectionAction
import io.element.android.features.messages.impl.timeline.SelectionState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@@ -78,6 +82,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_REASON
@@ -118,6 +123,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -289,17 +295,37 @@ class MessagesPresenterTest {
}
@Test
fun `present - handle action forward`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<EventId, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(
onForwardEventClickLambda = onForwardEventClickLambda,
fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest {
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
val presenter = createMessagesPresenter(
timelineEventSink = timelineEventSink,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
val presenter = createMessagesPresenter(navigator = navigator)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), any())
timelineEventSink.assertions().isCalledOnce()
.with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - handle action forward navigates directly when multi select is disabled`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(
onForwardEventClickLambda = onForwardEventClickLambda,
)
val presenter = createMessagesPresenter(
navigator = navigator,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID)), any())
}
}
@@ -1501,6 +1527,49 @@ class MessagesPresenterTest {
canPinUnpin = canPinUnpin,
)
@Test
fun `present - confirm selection action forwards the selection in timeline order`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
val presenter = createMessagesPresenter(
navigator = navigator,
timelineEventSink = timelineEventSink,
timelineState = aTimelineState(
// The timeline is rendered from the most recent event to the oldest one.
timelineItems = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
aTimelineItemEvent(eventId = AN_EVENT_ID),
),
selectionState = SelectionState.Active(
SelectionAction.Forward,
persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID),
),
eventSink = timelineEventSink,
),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2)), any())
timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - confirm selection action does nothing when no selection is active`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
val presenter = createMessagesPresenter(navigator = navigator)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
onForwardEventClickLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(),
@@ -1523,6 +1592,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
timelineEventSink: (TimelineEvent) -> Unit = {},
timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink),
permalinkParser: PermalinkParser = FakePermalinkParser(),
messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
aMessageComposerState(
@@ -1545,7 +1615,7 @@ class MessagesPresenterTest {
room = joinedRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelinePresenter = { timelineState },
timelineProtectionPresenter = { aTimelineProtectionState() },
identityChangeStatePresenter = { anIdentityChangeState() },
linkPresenter = { aLinkState() },
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import org.junit.Test
class SelectionActionTest {
@Test
fun `Forward - a remote text message can be selected`() {
assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent())).isTrue()
}
@Test
fun `Forward - a local echo cannot be selected`() {
assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent().copy(eventId = null))).isFalse()
}
@Test
fun `Forward - a failed to send message cannot be selected`() {
// A message which failed to send is a local echo: it has no event id.
val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")).copy(eventId = null)
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a state event cannot be selected`() {
val item = aTimelineItemEvent(content = aTimelineItemStateEventContent())
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a redacted event cannot be selected`() {
val item = aTimelineItemEvent(content = aTimelineItemRedactedContent())
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a sticker cannot be selected, it cannot be forwarded`() {
val item = aTimelineItemEvent(content = aTimelineItemStickerContent())
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a static location can be selected, it can be forwarded`() {
val item = aTimelineItemEvent(content = aTimelineItemLocationContent(mode = aStaticLocationMode()))
assertThat(SelectionAction.Forward.canApplyTo(item)).isTrue()
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EVENT_ID_3
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
import org.junit.Test
class SelectionStateTest {
@Test
fun `eventIdsToDeselect - selection mode being disabled has nothing to drop`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent()))
assertThat(SelectionState.Disabled.eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - an empty selection has nothing to drop`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent()))
assertThat(anActiveSelection().eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a selected event the action still applies to is kept`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID))
assertThat(anActiveSelection(listOf(AN_EVENT_ID)).eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a selected event which has been deleted is dropped`() {
val items = listOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()),
aTimelineItemEvent(eventId = AN_EVENT_ID),
)
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `eventIdsToDeselect - a selected event which is not in the timeline is ignored`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID))
// AN_EVENT_ID_3 may simply not be loaded anymore, so it is not dropped here.
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_3)).eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a deleted selected event folded into a group is dropped`() {
val items = listOf(
TimelineItem.GroupedEvents(
id = UniqueId("group"),
events = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()),
aTimelineItemEvent(eventId = AN_EVENT_ID_3, content = aTimelineItemRedactedContent()),
),
aggregatedReadReceipts = persistentListOf(),
),
aTimelineItemEvent(eventId = AN_EVENT_ID),
)
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `deselect - removes the given events and keeps selection mode active`() {
val selection = anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2))
assertThat(selection.deselect(setOf(AN_EVENT_ID))).isEqualTo(anActiveSelection(listOf(AN_EVENT_ID_2)))
assertThat(selection.deselect(setOf(AN_EVENT_ID, AN_EVENT_ID_2))).isEqualTo(anActiveSelection())
}
@Test
fun `deselect - does nothing when selection mode is disabled`() {
assertThat(SelectionState.Disabled.deselect(setOf(AN_EVENT_ID))).isEqualTo(SelectionState.Disabled)
}
private fun anActiveSelection(eventIds: List<EventId> = emptyList()) = SelectionState.Active(
action = SelectionAction.Forward,
selectedEventIds = eventIds.toImmutableSet(),
)
}
@@ -38,6 +38,7 @@ import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
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
@@ -91,6 +92,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
@@ -1861,6 +1863,152 @@ 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(SelectionAction.Forward, AN_EVENT_ID))
val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, 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(SelectionAction.Forward, AN_EVENT_ID))
expectNoEvents()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggling the last selected event keeps selection mode active`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
// Selection mode is only exited explicitly, so an empty selection keeps it active.
val state = consumeItemsUntilPredicate { it.selectedCount == 0 }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf()))
assertThat(state.isSelectionModeActive).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - exiting selection mode clears the selection`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ExitSelectionMode)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@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(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a selected event which cannot be forwarded anymore is deselected`() = runTest {
val timelineItems = MutableStateFlow(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID_2))
consumeItemsUntilPredicate { it.selectedCount == 2 }
// The first message is deleted, so it cannot be forwarded anymore.
timelineItems.value = listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aRedactedContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
val state = consumeItemsUntilPredicate { it.selectedCount == 1 }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2)))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - the selection survives a timeline which no longer contains the selected event`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
// The timeline no longer holds the selected event, for instance because it has been reset or because it switched to a detached timeline.
timelineItems.value = emptyList()
val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last()
// The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it.
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
@@ -1958,6 +2106,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead,
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EVENT_ID_3
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Test
class TimelineStateTest {
@Test
fun `selectedEventIdsInTimelineOrder - when selection is disabled, the list is empty`() {
val state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(eventId = AN_EVENT_ID)),
selectionState = SelectionState.Disabled,
)
assertThat(state.selectedEventIdsInTimelineOrder()).isEmpty()
}
@Test
fun `selectedEventIdsInTimelineOrder - the events are sorted from the oldest to the most recent one`() {
// The timeline items are rendered from the most recent to the oldest event.
val state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_3),
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
aTimelineItemEvent(eventId = AN_EVENT_ID),
),
// The events have been tapped in a random order.
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID, AN_EVENT_ID_3)),
)
assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID, AN_EVENT_ID_2, AN_EVENT_ID_3).inOrder()
}
@Test
fun `selectedEventIdsInTimelineOrder - the events which are not in the timeline are ignored`() {
val state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
aTimelineItemEvent(eventId = AN_EVENT_ID),
),
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID_3)),
)
assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `canSelect - returns false when selection mode is disabled`() {
val event = aTimelineItemEvent(eventId = AN_EVENT_ID)
val state = aTimelineState(
timelineItems = persistentListOf(event),
selectionState = SelectionState.Disabled,
)
assertThat(state.canSelect(event)).isFalse()
}
@Test
fun `canSelect - follows the action predicate when selection mode is active`() {
val forwardable = aTimelineItemEvent(eventId = AN_EVENT_ID)
val notForwardable = aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent())
val state = aTimelineState(
timelineItems = persistentListOf(forwardable, notForwardable),
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)),
)
assertThat(state.canSelect(forwardable)).isTrue()
assertThat(state.canSelect(notForwardable)).isFalse()
}
}
@@ -154,6 +154,14 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
MessageMultiSelect(
key = "feature.message_multi_select",
title = "Multi-select messages",
description = "The Forward action enters a selection mode, to pick and forward up to 10 messages at once.",
defaultValue = { false },
isFinished = false,
isInLabs = false,
),
NewTimelineEventRenderer(
key = "feature.new_timeline_event_renderer",
title = "New timeline event renderer",
@@ -316,12 +316,13 @@ interface Timeline : AutoCloseable {
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
/**
* Forwards the content of an event to other rooms, as new messages sent by the current user.
* Forwards the content of some events to other rooms, as new messages sent by the current user.
* The events are forwarded in the order of [eventIds].
*
* @param eventId the event whose content is forwarded.
* @param roomIds the rooms to forward it to.
* @param eventIds the events whose content is forwarded.
* @param roomIds the rooms to forward them to.
*/
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit>
/**
* Cancels an event that is still waiting in the send queue, by redacting its local echo.
@@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.ForwardEventException
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.RoomListService
@@ -31,33 +30,36 @@ class RoomContentForwarder(
private val roomListService: RoomListService,
) {
/**
* Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds].
* @param fromTimeline the room to forward the event from
* @param eventId the id of the event to forward
* @param toRoomIds the ids of the rooms to forward the event to
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
* Forwards the events with the given [eventIds] from the [fromTimeline] to the given [toRoomIds].
* The events are sent to each room in the order they are provided in [eventIds], it is up to the caller to sort them.
* @param fromTimeline the room to forward the events from
* @param eventIds the ids of the events to forward, in the order they must be sent
* @param toRoomIds the ids of the rooms to forward the events to
* @param timeoutMs the maximum time in milliseconds to wait for an event to be sent to a room
*/
suspend fun forward(
fromTimeline: Timeline,
eventId: EventId,
eventIds: List<EventId>,
toRoomIds: List<RoomId>,
timeoutMs: Long = 5000L
) {
val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content
?: throw ForwardEventException(toRoomIds)
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
val contents = eventIds.map { eventId ->
val timelineEvent = fromTimeline.getEventTimelineItemByEventId(eventId.value)
val content = timelineEvent.content as? TimelineItemContent.MsgLike ?: throw ForwardEventException(toRoomIds)
val message = content.content.kind as? MsgLikeKind.Message ?: throw ForwardEventException(toRoomIds)
contentWithoutRelationFromMessage(message.content)
}
val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
runCatchingExceptions {
// Sending a message requires a registered timeline listener
targetRoom.timeline().runWithTimelineListenerRegistered {
withTimeout(timeoutMs.milliseconds) {
targetRoom.timeline().send(contentWithoutRelationFromMessage(content))
targetRoom.timeline().use { timeline ->
// Send the contents sequentially, so that they are received in the same order
contents.forEach { content ->
withTimeout(timeoutMs.milliseconds) {
timeline.send(content)
}
}
}
}
@@ -53,16 +53,3 @@ internal fun TimelineInterface.timelineDiffFlow(): Flow<List<TimelineDiff>> =
}.catch {
Timber.d(it, "timelineDiffFlow() failed")
}.buffer(Channel.UNLIMITED)
internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
val result = addListener(NoOpTimelineListener)
try {
action()
} finally {
result.cancelAndDestroy()
}
}
private object NoOpTimelineListener : TimelineListener {
override fun onUpdate(diff: List<TimelineDiff>) = Unit
}
@@ -489,9 +489,9 @@ class RustTimeline(
}
}
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
roomContentForwarder.forward(fromTimeline = inner, eventIds = eventIds, toRoomIds = roomIds)
}.onFailure {
Timber.tag(loggerTag).e(it)
}
@@ -355,10 +355,10 @@ class FakeTimeline(
)
}
var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> lambdaError() }
var forwardEventsLambda: (eventIds: List<EventId>, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> lambdaError() }
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventLambda(eventId, roomIds)
override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventsLambda(eventIds, roomIds)
}
var createPollLambda: (
@@ -153,7 +153,7 @@ fun RoomSelectView(
topBar = {
TopAppBar(
titleStr = when (state.mode) {
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message)
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_to)
RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
},
navigationIcon = {
@@ -66,6 +66,7 @@ class KonsistClassNameTest {
"EditableAvatarViewUriPreviewParam",
"LoginModeViewErrorPreviewParam",
"OverlapRatioPreviewParam",
"SelectionCountPreviewParam",
"TextFileContentPreviewParam",
"HtmlMessageContentPreviewParam",
)
@@ -110,6 +110,7 @@ class KonsistPreviewTest {
"MessageComposerViewVoicePreview",
"MessagesReactionButtonAddPreview",
"MessagesReactionButtonExtraPreview",
"MessagesViewSelectionModePreview",
"MessagesViewWithIdentityChangePreview",
"PendingMemberRowWithLongNamePreview",
"PinUnlockViewInAppPreview",
@@ -133,6 +134,7 @@ class KonsistPreviewTest {
"SasEmojisPreview",
"SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview",
"SelectionIndicatorSelectedPreview",
"SpaceMembersViewNoHeroesPreview",
"SyncStateViewServerUnreachablePreview",
"TextComposerAddCaptionPreview",
@@ -179,6 +181,7 @@ class KonsistPreviewTest {
"TimelineItemVoiceViewScanningContentPreview",
"TimelineItemVoiceViewUnifiedPreview",
"TimelineViewMessageShieldPreview",
"TimelineViewSelectionModePreview",
"TimelineViewWithReadMarkerBothIndicatorsPreview",
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
"UserAvatarColorsPreview",