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