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