diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6a019d865b..bcbfc35255 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -402,6 +402,10 @@ class MessagesPresenter( TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) + TimelineItemAction.Select -> { + val eventId = targetEvent.eventId ?: return@launch + timelineState.eventSink(TimelineEvent.EnterSelectionMode(eventId)) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 16ca023d50..c4bcf552b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -89,12 +91,15 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent +import io.element.android.features.messages.impl.timeline.isSelectionModeActive import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.selectedCount import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView import io.element.android.features.messages.impl.topbars.MessagesViewTopBar +import io.element.android.features.messages.impl.topbars.SelectionModeTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog @@ -177,6 +182,14 @@ fun MessagesView( block() } + // Entering selection mode collapses the composer, so dismiss the keyboard too. hideKeyboard + // triggers the IME hide animation, keeping it in sync with the composer collapse. + LaunchedEffect(state.timelineState.isSelectionModeActive) { + if (state.timelineState.isSelectionModeActive) { + localView.hideKeyboard() + } + } + fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") val eventId = event.eventId @@ -233,34 +246,45 @@ fun MessagesView( Scaffold( contentWindowInsets = scaffoldScrollableContentInsets, topBar = { - if (state.timelineState.timelineMode is Timeline.Mode.Thread) { - ThreadTopBar( - roomName = state.roomName, - roomAvatarData = state.roomAvatar, - heroes = state.heroes, - isTombstoned = state.isTombstoned, - onBackClick = onBackClick, - ) - } else { - MessagesViewTopBar( - roomName = state.roomName, - roomAvatar = state.roomAvatar, - isTombstoned = state.isTombstoned, - heroes = state.heroes, - dmUserIdentityState = state.dmUserVerificationState, - sharedHistoryIcon = state.topBarSharedHistoryIcon, - dmUserStatus = state.dmUserStatus, - onBackClick = { hidingKeyboard { onBackClick() } }, - onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - menuActions = { - MessagesMenuActions( - displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, - roomCallState = state.roomCallState, - onJoinCallClick = onJoinCallClick, - onThreadsListClick = onThreadsListClick - ) - } - ) + when { + state.timelineState.isSelectionModeActive -> { + SelectionModeTopBar( + selectedCount = state.timelineState.selectedCount, + onCancelClick = { + state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) + }, + ) + } + state.timelineState.timelineMode is Timeline.Mode.Thread -> { + ThreadTopBar( + roomName = state.roomName, + roomAvatarData = state.roomAvatar, + heroes = state.heroes, + isTombstoned = state.isTombstoned, + onBackClick = onBackClick, + ) + } + else -> { + MessagesViewTopBar( + roomName = state.roomName, + roomAvatar = state.roomAvatar, + isTombstoned = state.isTombstoned, + heroes = state.heroes, + dmUserIdentityState = state.dmUserVerificationState, + sharedHistoryIcon = state.topBarSharedHistoryIcon, + dmUserStatus = state.dmUserStatus, + onBackClick = { hidingKeyboard { onBackClick() } }, + onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, + menuActions = { + MessagesMenuActions( + displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, + roomCallState = state.roomCallState, + onJoinCallClick = onJoinCallClick, + onThreadsListClick = onThreadsListClick + ) + } + ) + } } }, content = { padding -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 8caa95f31d..66ed7dd757 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment @@ -109,6 +110,7 @@ class DefaultActionListPresenter( }.collectAsState(initial = persistentListOf()) val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) + val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false) fun handleEvent(event: ActionListEvent) { when (event) { @@ -120,6 +122,7 @@ class DefaultActionListPresenter( pinnedEventIds = pinnedEventIds, target = target, isThreadsEnabled = isThreadsEnabled.value, + isMultiSelectEnabled = isMultiSelectEnabled.value, ) } } @@ -137,6 +140,7 @@ class DefaultActionListPresenter( pinnedEventIds: ImmutableList, target: MutableState, isThreadsEnabled: Boolean, + isMultiSelectEnabled: Boolean, ) = launch { target.value = ActionListState.Target.Loading(timelineItem) @@ -146,6 +150,7 @@ class DefaultActionListPresenter( isDeveloperModeEnabled = isDeveloperModeEnabled, isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isThreadsEnabled = isThreadsEnabled, + isMultiSelectEnabled = isMultiSelectEnabled, ) val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) @@ -179,6 +184,7 @@ class DefaultActionListPresenter( isDeveloperModeEnabled: Boolean, isEventPinned: Boolean, isThreadsEnabled: Boolean, + isMultiSelectEnabled: Boolean, ): List { val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther return buildSet { @@ -200,6 +206,9 @@ class DefaultActionListPresenter( if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { add(TimelineItemAction.Forward) } + if (isMultiSelectEnabled && timelineItem.canBeSelected()) { + add(TimelineItemAction.Select) + } if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.content is TimelineItemEventContentWithAttachment || timelineItem.content is TimelineItemGalleryContent || diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 3c64223e59..c7ee198ebc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -20,6 +20,7 @@ enum class TimelineItemAction( ) { ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on), Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward), + Select(CommonStrings.action_select, CompoundDrawables.ic_compound_check), CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy), CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy), CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 2f6fa4e4e0..86b62794ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -19,6 +19,7 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.Reply, TimelineItemAction.ReplyInThread, TimelineItemAction.Forward, + TimelineItemAction.Select, TimelineItemAction.Edit, TimelineItemAction.EditPoll, TimelineItemAction.AddCaption, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 5daec3e275..f751250c90 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.link.LinkEvent import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView @@ -291,6 +292,7 @@ private fun PinnedMessagesListLoaded( onContentLayoutChange = onContentLayoutChange ) }, + selectionData = TimelineItemSelectionData(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt new file mode 100644 index 0000000000..91b0d2e73a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -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) : SelectionState +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 66807b74f2..f21bfe88aa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -29,6 +29,10 @@ sealed interface TimelineEvent { data object MarkAllAsRead : TimelineEvent + data class EnterSelectionMode(val eventId: EventId) : TimelineEvent + data class ToggleSelection(val eventId: EventId) : TimelineEvent + data object ExitSelectionMode : TimelineEvent + /** * Events coming from a timeline item. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 2fc4351cea..80812264fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -25,6 +25,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState @@ -33,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent @@ -46,6 +48,8 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -68,7 +72,9 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analyticsproviders.api.AnalyticsUserData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine @@ -100,6 +106,7 @@ class TimelinePresenter( private val typingNotificationPresenter: Presenter, private val roomCallStatePresenter: Presenter, private val featureFlagService: FeatureFlagService, + private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val markAsFullyRead: MarkAsFullyRead, @@ -164,6 +171,18 @@ class TimelinePresenter( value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) } + val isMultiSelectEnabled by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it } + } + var selectionState by remember { mutableStateOf(SelectionState.Disabled) } + + // When the feature flag is turned off, clear any active selection. + LaunchedEffect(isMultiSelectEnabled) { + if (!isMultiSelectEnabled) { + selectionState = SelectionState.Disabled + } + } + val timelineProtectionState = timelineProtectionPresenter.present() fun handleEvent(event: TimelineEvent) { @@ -244,6 +263,28 @@ class TimelinePresenter( is TimelineEvent.JumpToLive -> { timelineController.focusOnLive() } + is TimelineEvent.EnterSelectionMode -> { + if (!isMultiSelectEnabled) return + selectionState = SelectionState.Active(persistentSetOf(event.eventId)) + } + is TimelineEvent.ToggleSelection -> { + val current = selectionState as? SelectionState.Active ?: return + val selected = current.selectedEventIds + selectionState = when { + event.eventId in selected -> { + val next = (selected - event.eventId).toPersistentSet() + if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(next) + } + selected.size >= MAX_SELECTION_COUNT -> { + snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached)) + current + } + else -> SelectionState.Active((selected + event.eventId).toPersistentSet()) + } + } + is TimelineEvent.ExitSelectionMode -> { + selectionState = SelectionState.Disabled + } TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { @@ -353,6 +394,23 @@ class TimelinePresenter( computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) } + // Reconcile the selection when the items change: drop any selected event that is no longer + // present or no longer selectable, and exit selection mode if nothing remains selected. + LaunchedEffect(timelineItems.map { it.identifier() }) { + val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect + val stillSelectable = timelineItems + .filterIsInstance() + .filter { it.canBeSelected() } + .mapNotNull { it.eventId } + .toSet() + val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet() + selectionState = when { + reconciled.isEmpty() -> SelectionState.Disabled + reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled) + else -> active + } + } + // Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the // read marker advances in place — the SDK swaps the marker virtual item to a new position // without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the @@ -463,6 +521,7 @@ class TimelinePresenter( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, + selectionState = selectionState, eventSink = ::handleEvent, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 1ebc96f15f..6bd5aa0895 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -37,6 +37,7 @@ data class TimelineState( val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, + val selectionState: SelectionState, val eventSink: (TimelineEvent) -> Unit, ) { private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event @@ -100,3 +101,14 @@ sealed interface JumpToUnreadState { /** The read marker event is older than the loaded window — load it via focused-event navigation. */ data class OutOfWindow(val eventId: EventId) : JumpToUnreadState } + +val TimelineState.isSelectionModeActive: Boolean + get() = selectionState is SelectionState.Active + +val TimelineState.selectedCount: Int + get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0 + +fun TimelineState.isSelected(eventId: EventId?): Boolean { + if (eventId == null) return false + return (selectionState as? SelectionState.Active)?.selectedEventIds?.contains(eventId) == true +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 89545b5d0b..5faea3de12 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -63,6 +63,7 @@ fun aTimelineState( displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, newEventState: NewEventState = NewEventState.None, + selectionState: SelectionState = SelectionState.Disabled, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId @@ -84,6 +85,7 @@ fun aTimelineState( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, + selectionState = selectionState, eventSink = eventSink, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 5e7fd34723..0df9be3ca0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -83,14 +84,17 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.androidutils.system.copyToClipboard @@ -111,6 +115,7 @@ import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -189,6 +194,18 @@ fun TimelineView( state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) } + val isSelectionModeActive = state.isSelectionModeActive + // Animate once here (shared by every row) rather than per-row: the value is identical for all + // rows since it only depends on the timeline-wide selection mode. + val selectionProgress by animateFloatAsState( + targetValue = if (isSelectionModeActive) 1f else 0f, + label = "selectionProgress", + ) + fun onToggleSelection(event: TimelineItem.Event) { + val eventId = event.eventId ?: return + state.eventSink(TimelineEvent.ToggleSelection(eventId)) + } + // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { Box(modifier) { @@ -214,12 +231,21 @@ fun TimelineView( isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, displayThreadSummaries = state.displayThreadSummaries, + selectionData = TimelineItemSelectionData( + isSelectionModeActive = isSelectionModeActive, + progress = selectionProgress, + isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId), + ), onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, - onContentClick = onContentClick, + onContentClick = { event -> + if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event) + }, onGalleryItemClick = onGalleryItemClick, - onLongClick = onMessageLongClick, + onLongClick = { event -> + if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event) + }, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -616,6 +642,36 @@ internal fun TimelineViewPreview( } } +@PreviewsDayNight +@Composable +internal fun TimelineViewSelectionModePreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + val selectedEventId = timelineItems.filterIsInstance().first { it.canBeSelected() }.eventId!! + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + TimelineView( + state = aTimelineState( + timelineItems = timelineItems, + selectionState = SelectionState.Active(persistentSetOf(selectedEventId)), + ), + timelineProtectionState = aTimelineProtectionState(), + onUserDataClick = {}, + onLinkClick = {}, + onContentClick = {}, + onMessageLongClick = {}, + onSwipeToReply = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onJoinCallClick = {}, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onGalleryItemClick = { _, _ -> }, + forceJumpToBottomVisibility = true, + ) + } +} + // The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in // production the FAB only appears when unread content exists above. So `hasUnreadAbove` // here only varies the scroll-target state, not the upper indicator's visibility. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt new file mode 100644 index 0000000000..e980b47155 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt @@ -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) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 99e72f4973..d8a8473891 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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) ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 5b620a7dc3..7094e302f8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -190,6 +190,7 @@ private fun TimelineItemGroupedEventsRowContent( onSwipeToReply = {}, eventSink = eventSink, eventContentView = eventContentView, + selectionData = TimelineItemSelectionData(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index fb9659ff70..7f08aa465e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -8,22 +8,30 @@ package io.element.android.features.messages.impl.timeline.components +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent @@ -31,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent @@ -60,6 +69,7 @@ internal fun TimelineItemRow( timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, displayThreadSummaries: Boolean, + selectionData: TimelineItemSelectionData, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -92,17 +102,130 @@ internal fun TimelineItemRow( ) }, ) { - val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { - val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { - 14.dp - } else { - 2.dp + val isSelectionModeActive = selectionData.isSelectionModeActive + val selectionProgress = selectionData.progress + val isSelected = selectionData.isSelected + val backgroundModifier = when { + isSelected -> { + Modifier.background(ElementTheme.colors.bgAccentSelected) + } + timelineItem.isEvent(focusedEventId) -> { + val focusedEventOffset = + if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { + 14.dp + } else { + 2.dp + } + Modifier.focusedEvent(focusedEventOffset) + } + else -> { + Modifier } - Modifier.focusedEvent(focusedEventOffset) - } else { - Modifier } - Box(modifier = modifier.then(backgroundModifier)) { + val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { it.canBeSelected() } + // Only start-aligned message bubbles slide to make room for the selection indicator: + // - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room. + // - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put. + val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true + val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing + val layoutDirection = LocalLayoutDirection.current + val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp + Box( + modifier = modifier.then(backgroundModifier) + ) { + TimelineItemRowContent( + modifier = Modifier + .fillMaxWidth() + .offset(x = contentSlide), + selectionContentOffset = contentSlide, + timelineItem = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = isLastOutgoingMessage, + focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = onJoinCallClick, + onSwipeToReply = onSwipeToReply, + eventSink = eventSink, + eventContentView = eventContentView, + ) + if (selectableEvent != null && selectionProgress > 0f) { + val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f + SelectionIndicator( + selected = isSelected, + modifier = Modifier + .align(Alignment.CenterStart) + .graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) } + .padding(start = 12.dp) + .size(24.dp), + ) + } + if (isSelectionModeActive && selectableEvent != null) { + // In selection mode the whole row toggles selection. + Box( + modifier = Modifier + .matchParentSize() + .clickable { onContentClick(selectableEvent) } + ) + } + } +} + +/** Width reserved for the selection indicator; the row content slides by this amount. */ +private val SELECTION_COLUMN_WIDTH = 36.dp + +/** + * Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered + * tile (state event, legacy call invite, call/RTC notification, day divider, grouped events). + * Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection + * indicator; centered tiles stay put. + */ +private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event && + content !is TimelineItemStateContent && + content !is TimelineItemLegacyCallInviteContent && + content !is TimelineItemRtcNotificationContent + +@Suppress("LongMethod") +@Composable +private fun TimelineItemRowContent( + timelineItem: TimelineItem, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + isLastOutgoingMessage: Boolean, + timelineProtectionState: TimelineProtectionState, + focusedEventId: EventId?, + displayThreadSummaries: Boolean, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: (TimelineItem.Event, Int) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, + modifier: Modifier = Modifier, + selectionContentOffset: Dp = 0.dp, +) { + Box(modifier) { when (timelineItem) { is TimelineItem.Virtual -> { TimelineItemVirtualRow( @@ -197,6 +320,7 @@ internal fun TimelineItemRow( onSwipeToReply = { onSwipeToReply(timelineItem) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, eventSink = eventSink, + selectionContentOffset = selectionContentOffset, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt new file mode 100644 index 0000000000..f8de354aac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt @@ -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, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt new file mode 100644 index 0000000000..9f11e86213 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt new file mode 100644 index 0000000000..55563d1f1b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -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 { + override val values = sequenceOf(0, 1, 5) +} diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..5dfeaa7b85 --- /dev/null +++ b/features/messages/impl/src/main/res/values/temporary.xml @@ -0,0 +1,15 @@ + + + + + + %1$d selected + %1$d selected + + You can select up to 30 messages + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index e987a32480..2988fbe894 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -70,6 +70,44 @@ class ActionListPresenterTest { } } + @Test + fun `present - Select action is present for a remote message when multi-select is enabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = aMessageEvent(), + userEventPermissions = aUserEventPermissions(canSendMessage = true), + ) + ) + val successState = awaitItem() + assertThat((successState.target as ActionListState.Target.Success).actions).contains(TimelineItemAction.Select) + } + } + + @Test + fun `present - Select action is absent when multi-select is disabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = aMessageEvent(), + userEventPermissions = aUserEventPermissions(canSendMessage = true), + ) + ) + val successState = awaitItem() + assertThat((successState.target as ActionListState.Target.Success).actions).doesNotContain(TimelineItemAction.Select) + } + } + @Test fun `present - compute for message from me redacted`() = runTest { val presenter = createActionListPresenter(isDeveloperModeEnabled = true) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 290246ffb9..6dcf107e0b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -34,6 +34,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId @@ -86,6 +87,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -1821,6 +1823,80 @@ class TimelinePresenterTest { } } + @Test + fun `present - entering selection mode selects the target event`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID))) + assertThat(state.selectedCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - entering selection mode is ignored when the flag is off`() = runTest { + val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)) + val presenter = createTimelinePresenter(featureFlagService = featureFlagService) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + expectNoEvents() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggling the last selected event exits selection mode`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - turning the flag off clears the active selection`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = featureFlagService, + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } @@ -1911,6 +1987,7 @@ class TimelinePresenterTest { typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, + snackbarDispatcher = SnackbarDispatcher(), analyticsService = FakeAnalyticsService(), liveLocationShareManager = liveLocationShareManager, markAsFullyRead = markAsFullyRead, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt new file mode 100644 index 0000000000..e293246900 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt @@ -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() + } +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index af7ccef97f..a50ebce5d7 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -161,4 +161,11 @@ enum class FeatureFlags( defaultValue = { 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, + ), }