mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Timeline : initial multi-select state
This commit is contained in:
+4
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+52
-28
@@ -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 ->
|
||||
|
||||
+9
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.canBeSelected
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
@@ -109,6 +110,7 @@ class DefaultActionListPresenter(
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
||||
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||
val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false)
|
||||
|
||||
fun handleEvent(event: ActionListEvent) {
|
||||
when (event) {
|
||||
@@ -120,6 +122,7 @@ class DefaultActionListPresenter(
|
||||
pinnedEventIds = pinnedEventIds,
|
||||
target = target,
|
||||
isThreadsEnabled = isThreadsEnabled.value,
|
||||
isMultiSelectEnabled = isMultiSelectEnabled.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,6 +140,7 @@ class DefaultActionListPresenter(
|
||||
pinnedEventIds: ImmutableList<EventId>,
|
||||
target: MutableState<ActionListState.Target>,
|
||||
isThreadsEnabled: Boolean,
|
||||
isMultiSelectEnabled: Boolean,
|
||||
) = launch {
|
||||
target.value = ActionListState.Target.Loading(timelineItem)
|
||||
|
||||
@@ -146,6 +150,7 @@ class DefaultActionListPresenter(
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
|
||||
isThreadsEnabled = isThreadsEnabled,
|
||||
isMultiSelectEnabled = isMultiSelectEnabled,
|
||||
)
|
||||
|
||||
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
|
||||
@@ -179,6 +184,7 @@ class DefaultActionListPresenter(
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
isEventPinned: Boolean,
|
||||
isThreadsEnabled: Boolean,
|
||||
isMultiSelectEnabled: Boolean,
|
||||
): List<TimelineItemAction> {
|
||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||
return buildSet {
|
||||
@@ -200,6 +206,9 @@ class DefaultActionListPresenter(
|
||||
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
if (isMultiSelectEnabled && timelineItem.canBeSelected()) {
|
||||
add(TimelineItemAction.Select)
|
||||
}
|
||||
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
|
||||
if (timelineItem.content is TimelineItemEventContentWithAttachment ||
|
||||
timelineItem.content is TimelineItemGalleryContent ||
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Select,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.AddCaption,
|
||||
|
||||
+2
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
/** Maximum number of messages that can be selected at once. */
|
||||
const val MAX_SELECTION_COUNT = 30
|
||||
|
||||
@Immutable
|
||||
sealed interface SelectionState {
|
||||
/** Selection mode is not active. */
|
||||
data object Disabled : SelectionState
|
||||
|
||||
/** Selection mode is active with the given selection. */
|
||||
data class Active(val selectedEventIds: ImmutableSet<EventId>) : SelectionState
|
||||
}
|
||||
+4
@@ -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.
|
||||
*/
|
||||
|
||||
+59
@@ -25,6 +25,7 @@ import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
||||
@@ -33,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.canBeSelected
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
@@ -46,6 +48,8 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
@@ -68,7 +72,9 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -100,6 +106,7 @@ class TimelinePresenter(
|
||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
@@ -164,6 +171,18 @@ class TimelinePresenter(
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
|
||||
val isMultiSelectEnabled by produceState(false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it }
|
||||
}
|
||||
var selectionState by remember { mutableStateOf<SelectionState>(SelectionState.Disabled) }
|
||||
|
||||
// When the feature flag is turned off, clear any active selection.
|
||||
LaunchedEffect(isMultiSelectEnabled) {
|
||||
if (!isMultiSelectEnabled) {
|
||||
selectionState = SelectionState.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
@@ -244,6 +263,28 @@ class TimelinePresenter(
|
||||
is TimelineEvent.JumpToLive -> {
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
is TimelineEvent.EnterSelectionMode -> {
|
||||
if (!isMultiSelectEnabled) return
|
||||
selectionState = SelectionState.Active(persistentSetOf(event.eventId))
|
||||
}
|
||||
is TimelineEvent.ToggleSelection -> {
|
||||
val current = selectionState as? SelectionState.Active ?: return
|
||||
val selected = current.selectedEventIds
|
||||
selectionState = when {
|
||||
event.eventId in selected -> {
|
||||
val next = (selected - event.eventId).toPersistentSet()
|
||||
if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(next)
|
||||
}
|
||||
selected.size >= MAX_SELECTION_COUNT -> {
|
||||
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached))
|
||||
current
|
||||
}
|
||||
else -> SelectionState.Active((selected + event.eventId).toPersistentSet())
|
||||
}
|
||||
}
|
||||
is TimelineEvent.ExitSelectionMode -> {
|
||||
selectionState = SelectionState.Disabled
|
||||
}
|
||||
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
|
||||
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
|
||||
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||
@@ -353,6 +394,23 @@ class TimelinePresenter(
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
// Reconcile the selection when the items change: drop any selected event that is no longer
|
||||
// present or no longer selectable, and exit selection mode if nothing remains selected.
|
||||
LaunchedEffect(timelineItems.map { it.identifier() }) {
|
||||
val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect
|
||||
val stillSelectable = timelineItems
|
||||
.filterIsInstance<TimelineItem.Event>()
|
||||
.filter { it.canBeSelected() }
|
||||
.mapNotNull { it.eventId }
|
||||
.toSet()
|
||||
val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet()
|
||||
selectionState = when {
|
||||
reconciled.isEmpty() -> SelectionState.Disabled
|
||||
reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled)
|
||||
else -> active
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
|
||||
// read marker advances in place — the SDK swaps the marker virtual item to a new position
|
||||
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
|
||||
@@ -463,6 +521,7 @@ class TimelinePresenter(
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
selectionState = selectionState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
}
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ fun aTimelineState(
|
||||
displayJumpToUnread: Boolean = false,
|
||||
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
|
||||
newEventState: NewEventState = NewEventState.None,
|
||||
selectionState: SelectionState = SelectionState.Disabled,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
@@ -84,6 +85,7 @@ fun aTimelineState(
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
selectionState = selectionState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
+58
-2
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -83,14 +84,17 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
|
||||
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData
|
||||
import io.element.android.features.messages.impl.timeline.components.toText
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.canBeSelected
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
@@ -111,6 +115,7 @@ import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -189,6 +194,18 @@ fun TimelineView(
|
||||
state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
}
|
||||
|
||||
val isSelectionModeActive = state.isSelectionModeActive
|
||||
// Animate once here (shared by every row) rather than per-row: the value is identical for all
|
||||
// rows since it only depends on the timeline-wide selection mode.
|
||||
val selectionProgress by animateFloatAsState(
|
||||
targetValue = if (isSelectionModeActive) 1f else 0f,
|
||||
label = "selectionProgress",
|
||||
)
|
||||
fun onToggleSelection(event: TimelineItem.Event) {
|
||||
val eventId = event.eventId ?: return
|
||||
state.eventSink(TimelineEvent.ToggleSelection(eventId))
|
||||
}
|
||||
|
||||
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||
AnimatedVisibility(visible = true, enter = fadeIn()) {
|
||||
Box(modifier) {
|
||||
@@ -214,12 +231,21 @@ fun TimelineView(
|
||||
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
|
||||
focusedEventId = state.focusedEventId,
|
||||
displayThreadSummaries = state.displayThreadSummaries,
|
||||
selectionData = TimelineItemSelectionData(
|
||||
isSelectionModeActive = isSelectionModeActive,
|
||||
progress = selectionProgress,
|
||||
isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId),
|
||||
),
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = ::onLinkLongClick,
|
||||
onContentClick = onContentClick,
|
||||
onContentClick = { event ->
|
||||
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event)
|
||||
},
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
onLongClick = { event ->
|
||||
if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event)
|
||||
},
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
@@ -616,6 +642,36 @@ internal fun TimelineViewPreview(
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineViewSelectionModePreview() = ElementPreview {
|
||||
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
|
||||
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { it.canBeSelected() }.eventId!!
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
TimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
selectionState = SelectionState.Active(persistentSetOf(selectedEventId)),
|
||||
),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onContentClick = {},
|
||||
onMessageLongClick = {},
|
||||
onSwipeToReply = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onJoinCallClick = {},
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
|
||||
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
|
||||
// here only varies the scroll-target state, not the upper indicator's visibility.
|
||||
|
||||
+54
@@ -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)
|
||||
}
|
||||
+9
-1
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -190,6 +190,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
onSwipeToReply = {},
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
selectionData = TimelineItemSelectionData(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+133
-9
@@ -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)
|
||||
},
|
||||
|
||||
+24
@@ -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,
|
||||
)
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2026 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.topbars
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectionModeTopBar(
|
||||
selectedCount: Int,
|
||||
onCancelClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(text = pluralStringResource(R.plurals.screen_room_timeline_selection_title, selectedCount, selectedCount))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onCancelClick,
|
||||
) {
|
||||
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SelectionModeTopBarPreview(
|
||||
@PreviewParameter(SelectionCountProvider::class) count: Int,
|
||||
) = ElementPreview {
|
||||
SelectionModeTopBar(selectedCount = count, onCancelClick = {})
|
||||
}
|
||||
|
||||
internal class SelectionCountProvider : PreviewParameterProvider<Int> {
|
||||
override val values = sequenceOf(0, 1, 5)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2026 New Vector Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<!-- %1$d is the number of selected messages -->
|
||||
<plurals name="screen_room_timeline_selection_title">
|
||||
<item quantity="one">%1$d selected</item>
|
||||
<item quantity="other">%1$d selected</item>
|
||||
</plurals>
|
||||
<string name="screen_room_timeline_selection_limit_reached">You can select up to 30 messages</string>
|
||||
</resources>
|
||||
+38
@@ -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)
|
||||
|
||||
+77
@@ -34,6 +34,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -86,6 +87,7 @@ import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -1821,6 +1823,80 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - entering selection mode selects the target event`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID)))
|
||||
assertThat(state.selectedCount).isEqualTo(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - entering selection mode is ignored when the flag is off`() = runTest {
|
||||
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false))
|
||||
val presenter = createTimelinePresenter(featureFlagService = featureFlagService)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
expectNoEvents()
|
||||
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - toggling the last selected event exits selection mode`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - turning the flag off clears the active selection`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true))
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
return awaitItem()
|
||||
}
|
||||
@@ -1911,6 +1987,7 @@ class TimelinePresenterTest {
|
||||
typingNotificationPresenter = { aTypingNotificationState() },
|
||||
roomCallStatePresenter = { aStandByCallState() },
|
||||
featureFlagService = featureFlagService,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
|
||||
+47
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user