Threads - first iteration (#5165)

* Initial threads support: parse `ThreadSummary`.

Replace several `isThreaded` values with `EventThreadInfo`, which contains the info about the event either being the root of a thread or part of it.

* Add `Threaded` timeline mode

* Add a `liveTimeline` parameter to `TimelineController`'s  constructor. This way we can customise which timeline will be used as the 'live' one. Also add `@LiveTimeline` DI qualifier for the actual live timeline of the room.

* Create `ThreadedMessagesNode`. Allow opening a thread in a separate screen.

* Add the callbacks for the list menu actions - even if they're the wrong ones and will send the data to the room instead

* Send attachments and location in threads

* Fix polls in threads, add support for sending voice messages in threads

* Display thread summaries only when the feature flag is enabled

* Use 'Reply' instead of 'Reply in thread' when in threaded timeline mode

* Remove incorrect usage of `Timeline` in `MessageComposerPresenter`. This led to replies to threaded events not appearing as actual replies.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-08-19 15:35:48 +02:00 committed by GitHub
parent 8675bf5215
commit 285066c206
119 changed files with 1520 additions and 339 deletions

View file

@ -65,6 +65,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -7,11 +7,19 @@
package io.element.android.features.location.api package io.element.android.features.location.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.timeline.Timeline
/** /**
* The "Send location" screen. * The "Send location" screen.
* *
* Allows a user to share a location message within a room. * Allows a user to share a location message within a room.
*/ */
interface SendLocationEntryPoint : SimpleFeatureEntryPoint interface SendLocationEntryPoint : FeatureEntryPoint {
fun builder(timelineMode: Timeline.Mode): Builder
interface Builder {
fun build(parentNode: Node, buildContext: BuildContext): Node
}
}

View file

@ -13,12 +13,21 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultSendLocationEntryPoint @Inject constructor() : SendLocationEntryPoint { class DefaultSendLocationEntryPoint @Inject constructor() : SendLocationEntryPoint {
override fun createNode( override fun builder(timelineMode: Timeline.Mode): SendLocationEntryPoint.Builder {
parentNode: Node, return Builder(timelineMode)
buildContext: BuildContext }
): SendLocationNode = parentNode.createNode(buildContext)
class Builder(private val timelineMode: Timeline.Mode) : SendLocationEntryPoint.Builder {
override fun build(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<SendLocationNode>(
buildContext = buildContext,
plugins = listOf(SendLocationNode.Inputs(timelineMode))
)
}
}
} }

View file

@ -17,16 +17,25 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class SendLocationNode @AssistedInject constructor( class SendLocationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: SendLocationPresenter, presenterFactory: SendLocationPresenter.Factory,
analyticsService: AnalyticsService, analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs(
val timelineMode: Timeline.Mode,
) : NodeInputs
private val presenter = presenterFactory.create(inputs<Inputs>().timelineMode)
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {

View file

@ -15,6 +15,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.actions.LocationActions
@ -23,22 +26,30 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
class SendLocationPresenter @Inject constructor( class SendLocationPresenter @AssistedInject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val room: JoinedRoom, private val room: JoinedRoom,
@Assisted private val timelineMode: Timeline.Mode,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions, private val locationActions: LocationActions,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> { ) : Presenter<SendLocationState> {
@AssistedFactory
interface Factory {
fun create(timelineMode: Timeline.Mode): SendLocationPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable @Composable
@ -104,14 +115,16 @@ class SendLocationPresenter @Inject constructor(
when (mode) { when (mode) {
SendLocationState.Mode.PinLocation -> { SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri() val geoUri = event.cameraPosition.toGeoUri()
room.liveTimeline.sendLocation( getTimeline().flatMap {
body = generateBody(geoUri), it.sendLocation(
geoUri = geoUri, body = generateBody(geoUri),
description = null, geoUri = geoUri,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), description = null,
assetType = AssetType.PIN, zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
inReplyToEventId = inReplyToEventId, assetType = AssetType.PIN,
) inReplyToEventId = inReplyToEventId,
)
}
analyticsService.capture( analyticsService.capture(
Composer( Composer(
inThread = messageComposerContext.composerMode.inThread, inThread = messageComposerContext.composerMode.inThread,
@ -123,14 +136,16 @@ class SendLocationPresenter @Inject constructor(
} }
SendLocationState.Mode.SenderLocation -> { SendLocationState.Mode.SenderLocation -> {
val geoUri = event.toGeoUri() val geoUri = event.toGeoUri()
room.liveTimeline.sendLocation( getTimeline().flatMap {
body = generateBody(geoUri), it.sendLocation(
geoUri = geoUri, body = generateBody(geoUri),
description = null, geoUri = geoUri,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), description = null,
assetType = AssetType.SENDER, zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
inReplyToEventId = inReplyToEventId, assetType = AssetType.SENDER,
) inReplyToEventId = inReplyToEventId,
)
}
analyticsService.capture( analyticsService.capture(
Composer( Composer(
inThread = messageComposerContext.composerMode.inThread, inThread = messageComposerContext.composerMode.inThread,
@ -142,6 +157,13 @@ class SendLocationPresenter @Inject constructor(
} }
} }
} }
private suspend fun getTimeline(): Result<Timeline> {
return when (timelineMode) {
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
else -> Result.success(room.liveTimeline)
}
}
} }
private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri()

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
@ -55,6 +56,7 @@ class SendLocationPresenterTest {
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
}, },
room = joinedRoom, room = joinedRoom,
timelineMode = Timeline.Mode.Live,
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext, messageComposerContext = fakeMessageComposerContext,
locationActions = fakeLocationActions, locationActions = fakeLocationActions,

View file

@ -16,6 +16,7 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.messages.impl.voicemessages.composer package io.element.android.features.messages.api.timeline.voicemessages.composer
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.api.timeline.voicemessages.composer
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.timeline.Timeline
fun interface VoiceMessageComposerPresenter : Presenter<VoiceMessageComposerState> {
interface Factory {
fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.messages.impl.voicemessages.composer package io.element.android.features.messages.api.timeline.voicemessages.composer
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.messages.impl.voicemessages.composer package io.element.android.features.messages.api.timeline.voicemessages.composer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.media.createFakeWaveform import io.element.android.libraries.designsystem.components.media.createFakeWaveform
@ -13,14 +13,14 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> { open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState> override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf( get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = aWaveformLevels)), aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = aWaveformLevels)),
) )
} }
internal fun aVoiceMessageComposerState( fun aVoiceMessageComposerState(
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
keepScreenOn: Boolean = false, keepScreenOn: Boolean = false,
showPermissionRationaleDialog: Boolean = false, showPermissionRationaleDialog: Boolean = false,
@ -33,7 +33,7 @@ internal fun aVoiceMessageComposerState(
eventSink = {}, eventSink = {},
) )
internal fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
isSending = false, isSending = false,
isPlaying = false, isPlaying = false,
showCursor = false, showCursor = false,

View file

@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.forward.ForwardMessagesNode
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -65,6 +66,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
@ -139,7 +141,7 @@ class MessagesFlowNode @AssistedInject constructor(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
data class AttachmentPreview(val attachment: Attachment) : NavTarget data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment) : NavTarget
@Parcelize @Parcelize
data class LocationViewer(val location: Location, val description: String?) : NavTarget data class LocationViewer(val location: Location, val description: String?) : NavTarget
@ -154,19 +156,22 @@ class MessagesFlowNode @AssistedInject constructor(
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@Parcelize @Parcelize
data object SendLocation : NavTarget data class SendLocation(val timelineMode: Timeline.Mode) : NavTarget
@Parcelize @Parcelize
data object CreatePoll : NavTarget data class CreatePoll(val timelineMode: Timeline.Mode) : NavTarget
@Parcelize @Parcelize
data class EditPoll(val eventId: EventId) : NavTarget data class EditPoll(val timelineMode: Timeline.Mode, val eventId: EventId) : NavTarget
@Parcelize @Parcelize
data object PinnedMessagesList : NavTarget data object PinnedMessagesList : NavTarget
@Parcelize @Parcelize
data object KnockRequestsList : NavTarget data object KnockRequestsList : NavTarget
@Parcelize
data class OpenThread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
} }
private val callbacks = plugins<MessagesEntryPoint.Callback>() private val callbacks = plugins<MessagesEntryPoint.Callback>()
@ -211,15 +216,18 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() } callbacks.forEach { it.onRoomDetailsClick() }
} }
override fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean { override fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
return processEventClick( return processEventClick(
timelineMode = if (isLive) Timeline.Mode.LIVE else Timeline.Mode.FOCUSED_ON_EVENT, timelineMode = timelineMode,
event = event, event = event,
) )
} }
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) { override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(attachments.first())) backstack.push(NavTarget.AttachmentPreview(
attachment = attachments.first(),
timelineMode = Timeline.Mode.Live,
))
} }
override fun onUserDataClick(userId: UserId) { override fun onUserDataClick(userId: UserId) {
@ -243,15 +251,15 @@ class MessagesFlowNode @AssistedInject constructor(
} }
override fun onSendLocationClick() { override fun onSendLocationClick() {
backstack.push(NavTarget.SendLocation) backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
} }
override fun onCreatePollClick() { override fun onCreatePollClick() {
backstack.push(NavTarget.CreatePoll) backstack.push(NavTarget.CreatePoll(Timeline.Mode.Live))
} }
override fun onEditPollClick(eventId: EventId) { override fun onEditPollClick(eventId: EventId) {
backstack.push(NavTarget.EditPoll(eventId)) backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
} }
override fun onJoinCallClick(roomId: RoomId) { override fun onJoinCallClick(roomId: RoomId) {
@ -270,6 +278,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onViewKnockRequests() { override fun onViewKnockRequests() {
backstack.push(NavTarget.KnockRequestsList) backstack.push(NavTarget.KnockRequestsList)
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
}
} }
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs)) createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@ -298,7 +310,10 @@ class MessagesFlowNode @AssistedInject constructor(
.build() .build()
} }
is NavTarget.AttachmentPreview -> { is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment,
timelineMode = navTarget.timelineMode,
)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs)) createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
} }
is NavTarget.LocationViewer -> { is NavTarget.LocationViewer -> {
@ -327,24 +342,34 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
createNode<ReportMessageNode>(buildContext, listOf(inputs)) createNode<ReportMessageNode>(buildContext, listOf(inputs))
} }
NavTarget.SendLocation -> { is NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode(this, buildContext) sendLocationEntryPoint
.builder(navTarget.timelineMode)
.build(this, buildContext)
} }
NavTarget.CreatePoll -> { is NavTarget.CreatePoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext) createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.NewPoll)) .params(CreatePollEntryPoint.Params(
timelineMode = navTarget.timelineMode,
mode = CreatePollMode.NewPoll
))
.build() .build()
} }
is NavTarget.EditPoll -> { is NavTarget.EditPoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext) createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.eventId))) .params(
CreatePollEntryPoint.Params(
timelineMode = navTarget.timelineMode,
mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)
)
)
.build() .build()
} }
NavTarget.PinnedMessagesList -> { NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback { val callback = object : PinnedMessagesListNode.Callback {
override fun onEventClick(event: TimelineItem.Event) { override fun onEventClick(event: TimelineItem.Event) {
processEventClick( processEventClick(
timelineMode = Timeline.Mode.PINNED_EVENTS, timelineMode = Timeline.Mode.PinnedEvents,
event = event, event = event,
) )
} }
@ -377,6 +402,69 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.KnockRequestsList -> { NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext) knockRequestsListEntryPoint.createNode(this, buildContext)
} }
is NavTarget.OpenThread -> {
val inputs = ThreadedMessagesNode.Inputs(
threadRootEventId = navTarget.threadRootId,
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
)
}
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(
attachment = attachments.first(),
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId)
))
}
override fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
override fun onPermalinkClick(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = true) }
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun onForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
}
override fun onReportMessage(eventId: EventId, senderId: UserId) {
backstack.push(NavTarget.ReportMessage(eventId, senderId))
}
override fun onSendLocationClick() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
}
override fun onCreatePollClick() {
backstack.push(NavTarget.CreatePoll(Timeline.Mode.Thread(navTarget.threadRootId)))
}
override fun onEditPollClick(eventId: EventId) {
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun onJoinCallClick(roomId: RoomId) {
val callType = CallType.RoomCall(
sessionId = matrixClient.sessionId,
roomId = roomId,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
} }
} }

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -21,4 +22,5 @@ interface MessagesNavigator {
fun onEditPollClick(eventId: EventId) fun onEditPollClick(eventId: EventId)
fun onPreviewAttachment(attachments: ImmutableList<Attachment>) fun onPreviewAttachment(attachments: ImmutableList<Attachment>)
fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>)
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
} }

View file

@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
@ -55,12 +56,14 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -75,9 +78,8 @@ class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@SessionCoroutineScope @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionCoroutineScope: CoroutineScope, private val room: JoinedRoom,
private val room: BaseRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory, messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory, timelinePresenterFactory: TimelinePresenter.Factory,
@ -89,11 +91,16 @@ class MessagesNode @AssistedInject constructor(
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val timelineController = TimelineController(room, room.liveTimeline)
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
navigator = this, navigator = this,
composerPresenter = messageComposerPresenterFactory.create(this), composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(this), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default) actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode()
),
timelineController = timelineController,
) )
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -103,7 +110,7 @@ class MessagesNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomDetailsClick() fun onRoomDetailsClick()
fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>) fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClick(userId: UserId) fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData) fun onPermalinkClick(data: PermalinkData)
@ -116,6 +123,7 @@ class MessagesNode @AssistedInject constructor(
fun onJoinCallClick(roomId: RoomId) fun onJoinCallClick(roomId: RoomId)
fun onViewAllPinnedEvents() fun onViewAllPinnedEvents()
fun onViewKnockRequests() fun onViewKnockRequests()
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
} }
override fun onBuilt() { override fun onBuilt() {
@ -134,12 +142,12 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() } callbacks.forEach { it.onRoomDetailsClick() }
} }
private fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean { private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because: // Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false. // - if callbacks is empty, it will return true and we want to return false.
// - if a callback returns false, the other callback will not be invoked. // - if a callback returns false, the other callback will not be invoked.
return callbacks.takeIf { it.isNotEmpty() } return callbacks.takeIf { it.isNotEmpty() }
?.map { it.onEventClick(isLive, event) } ?.map { it.onEventClick(timelineMode, event) }
?.all { it } ?.all { it }
.orFalse() .orFalse()
} }
@ -223,6 +231,10 @@ class MessagesNode @AssistedInject constructor(
} }
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
}
private fun onViewAllPinnedMessagesClick() { private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() } callbacks.forEach { it.onViewAllPinnedEvents() }
} }
@ -265,7 +277,18 @@ class MessagesNode @AssistedInject constructor(
state = state, state = state,
onBackClick = this::navigateUp, onBackClick = this::navigateUp,
onRoomDetailsClick = this::onRoomDetailsClick, onRoomDetailsClick = this::onRoomDetailsClick,
onEventContentClick = this::onEventClick, onEventContentClick = { isLive, event ->
if (isLive) {
onEventClick(timelineController.mainTimelineMode(), event)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
onEventClick(detachedTimelineMode, event)
} else {
false
}
}
},
onUserDataClick = this::onUserDataClick, onUserDataClick = this::onUserDataClick,
onLinkClick = { url, customTab -> onLinkClick(activity, isDark, url, state.timelineState.eventSink, customTab) }, onLinkClick = { url, customTab -> onLinkClick(activity, isDark, url, state.timelineState.eventSink, customTab) },
onSendLocationClick = this::onSendLocationClick, onSendLocationClick = this::onSendLocationClick,

View file

@ -48,7 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
@ -93,7 +93,7 @@ class MessagesPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val room: JoinedRoom, private val room: JoinedRoom,
@Assisted private val composerPresenter: Presenter<MessageComposerState>, @Assisted private val composerPresenter: Presenter<MessageComposerState>,
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>, voiceMessageComposerPresenterFactory: DefaultVoiceMessageComposerPresenter.Factory,
@Assisted private val timelinePresenter: Presenter<TimelineState>, @Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>, private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
@ -111,7 +111,7 @@ class MessagesPresenter @AssistedInject constructor(
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val timelineController: TimelineController, @Assisted private val timelineController: TimelineController,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
@ -123,9 +123,14 @@ class MessagesPresenter @AssistedInject constructor(
composerPresenter: Presenter<MessageComposerState>, composerPresenter: Presenter<MessageComposerState>,
timelinePresenter: Presenter<TimelineState>, timelinePresenter: Presenter<TimelineState>,
actionListPresenter: Presenter<ActionListState>, actionListPresenter: Presenter<ActionListState>,
timelineController: TimelineController,
): MessagesPresenter ): MessagesPresenter
} }
private val voiceMessageComposerPresenter = voiceMessageComposerPresenterFactory.create(
timelineMode = timelineController.mainTimelineMode()
)
@Composable @Composable
override fun present(): MessagesState { override fun present(): MessagesState {
htmlConverterProvider.Update() htmlConverterProvider.Update()
@ -145,9 +150,8 @@ class MessagesPresenter @AssistedInject constructor(
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present() val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value) val userEventPermissions by userEventPermissions(roomInfo)
val roomAvatar by remember { val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() } derivedStateOf { roomInfo.avatarData() }
@ -264,8 +268,13 @@ class MessagesPresenter @AssistedInject constructor(
} }
@Composable @Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> { private fun userEventPermissions(roomInfo: RoomInfo): State<UserEventPermissions> {
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) {
Long.MAX_VALUE
} else {
roomInfo.roomPowerLevels?.hashCode() ?: 0L
}
return produceState(UserEventPermissions.DEFAULT, key1 = key) {
value = UserEventPermissions( value = UserEventPermissions(
canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).getOrElse { true }, canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).getOrElse { true },
canSendReaction = room.canSendMessage(type = MessageEventType.REACTION).getOrElse { true }, canSendReaction = room.canSendMessage(type = MessageEventType.REACTION).getOrElse { true },

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.link.LinkState
@ -18,7 +19,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData

View file

@ -8,6 +8,9 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
@ -31,9 +34,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState import io.element.android.features.roomcall.api.anOngoingCallState
@ -43,8 +43,10 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -84,6 +86,10 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.Verified), aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.Verified),
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.VerificationViolation), aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.VerificationViolation),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)), aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(timelineState = aTimelineState(
timelineMode = Timeline.Mode.Thread(threadRootId = ThreadId("\$a-thread-id")),
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
)),
) )
} }

View file

@ -48,6 +48,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -73,7 +74,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
@ -105,6 +105,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -196,17 +197,21 @@ fun MessagesView(
topBar = { topBar = {
Column { Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar( if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
roomName = state.roomName, ThreadTopBar(onBackClick = onBackClick)
roomAvatar = state.roomAvatar, } else {
isTombstoned = state.isTombstoned, MessagesViewTopBar(
heroes = state.heroes, roomName = state.roomName,
roomCallState = state.roomCallState, roomAvatar = state.roomAvatar,
dmUserIdentityState = state.dmUserVerificationState, isTombstoned = state.isTombstoned,
onBackClick = { hidingKeyboard { onBackClick() } }, heroes = state.heroes,
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, roomCallState = state.roomCallState,
onJoinCallClick = onJoinCallClick, dmUserIdentityState = state.dmUserVerificationState,
) onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
)
}
} }
}, },
content = { padding -> content = { padding ->
@ -414,23 +419,26 @@ private fun MessagesViewContent(
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
nestedScrollConnection = scrollBehavior.nestedScrollConnection, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
) )
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
enter = expandVertically(), AnimatedVisibility(
exit = shrinkVertically(), visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
) { enter = expandVertically(),
fun focusOnPinnedEvent(eventId: EventId) { exit = shrinkVertically(),
state.timelineState.eventSink( ) {
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
) )
} }
PinnedMessagesBannerView( knockRequestsBannerView()
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
} }
knockRequestsBannerView()
} }
} }
} }
@ -540,6 +548,21 @@ private fun MessagesViewTopBar(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThreadTopBar(
onBackClick: () -> Unit,
) {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {
Text(stringResource(CommonStrings.common_thread))
}
)
}
@Composable @Composable
private fun RoomAvatarAndNameRow( private fun RoomAvatarAndNameRow(
roomName: String?, roomName: String?,

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -51,13 +52,18 @@ import kotlinx.coroutines.launch
interface ActionListPresenter : Presenter<ActionListState> { interface ActionListPresenter : Presenter<ActionListState> {
interface Factory { interface Factory {
fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter fun create(
postProcessor: TimelineItemActionPostProcessor,
timelineMode: Timeline.Mode,
): ActionListPresenter
} }
} }
class DefaultActionListPresenter @AssistedInject constructor( class DefaultActionListPresenter @AssistedInject constructor(
@Assisted @Assisted
private val postProcessor: TimelineItemActionPostProcessor, private val postProcessor: TimelineItemActionPostProcessor,
@Assisted
private val timelineMode: Timeline.Mode,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val room: BaseRoom, private val room: BaseRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory, private val userSendFailureFactory: VerifiedUserSendFailureFactory,
@ -66,7 +72,10 @@ class DefaultActionListPresenter @AssistedInject constructor(
@AssistedFactory @AssistedFactory
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
interface Factory : ActionListPresenter.Factory { interface Factory : ActionListPresenter.Factory {
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter override fun create(
postProcessor: TimelineItemActionPostProcessor,
timelineMode: Timeline.Mode,
): DefaultActionListPresenter
} }
private val comparator = TimelineItemActionComparator() private val comparator = TimelineItemActionComparator()
@ -150,7 +159,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildSet { return buildSet {
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
if (timelineItem.isThreaded) { if (timelineMode !is Timeline.Mode.Thread && timelineItem.threadInfo.threadRootId != null) {
add(TimelineItemAction.ReplyInThread) add(TimelineItemAction.ReplyInThread)
} else { } else {
add(TimelineItemAction.Reply) add(TimelineItemAction.Reply)

View file

@ -20,6 +20,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -29,7 +30,10 @@ class AttachmentsPreviewNode @AssistedInject constructor(
presenterFactory: AttachmentsPreviewPresenter.Factory, presenterFactory: AttachmentsPreviewPresenter.Factory,
private val localMediaRenderer: LocalMediaRenderer, private val localMediaRenderer: LocalMediaRenderer,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs(val attachment: Attachment) : NodeInputs data class Inputs(
val attachment: Attachment,
val timelineMode: Timeline.Mode,
) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -39,6 +43,7 @@ class AttachmentsPreviewNode @AssistedInject constructor(
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
attachment = inputs.attachment, attachment = inputs.attachment,
timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
) )

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
@ -50,7 +51,8 @@ import timber.log.Timber
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
@Assisted private val onDoneListener: OnDoneListener, @Assisted private val onDoneListener: OnDoneListener,
private val mediaSender: MediaSender, @Assisted private val timelineMode: Timeline.Mode,
mediaSenderFactory: MediaSender.Factory,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
@ -61,10 +63,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
interface Factory { interface Factory {
fun create( fun create(
attachment: Attachment, attachment: Attachment,
timelineMode: Timeline.Mode,
onDoneListener: OnDoneListener, onDoneListener: OnDoneListener,
): AttachmentsPreviewPresenter ): AttachmentsPreviewPresenter
} }
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable @Composable
override fun present(): AttachmentsPreviewState { override fun present(): AttachmentsPreviewState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()

View file

@ -28,14 +28,12 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ContributesTo(RoomScope::class) @ContributesTo(RoomScope::class)
@Module @Module
interface MessagesModule { interface MessagesBindsModule {
@Binds @Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState> fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
@ -51,9 +49,6 @@ interface MessagesModule {
@Binds @Binds
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState> fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
@Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
@Binds @Binds
fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter<CustomReactionState> fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter<CustomReactionState>

View file

@ -0,0 +1,24 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
@ContributesTo(RoomScope::class)
@Module
object MessagesProvidesModule {
@Provides
@LiveTimeline
fun provideLiveTimeline(joinedRoom: JoinedRoom): Timeline = joinedRoom.liveTimeline
}

View file

@ -97,13 +97,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
class MessageComposerPresenter @AssistedInject constructor( class MessageComposerPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
@SessionCoroutineScope @Assisted private val timelineController: TimelineController,
private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom, private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaSender: MediaSender, private val mediaSenderFactory: MediaSender.Factory,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val locationService: LocationService, private val locationService: LocationService,
@ -113,7 +113,6 @@ class MessageComposerPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val timelineController: TimelineController,
private val draftService: ComposerDraftService, private val draftService: ComposerDraftService,
private val mentionSpanProvider: MentionSpanProvider, private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper, private val pillificationHelper: TextPillificationHelper,
@ -122,9 +121,11 @@ class MessageComposerPresenter @AssistedInject constructor(
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(navigator: MessagesNavigator): MessageComposerPresenter fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
} }
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null private var pendingEvent: MessageComposerEvents? = null
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null) private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
@ -423,11 +424,13 @@ class MessageComposerPresenter @AssistedInject constructor(
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Attachment, is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> room.liveTimeline.sendMessage( is MessageComposerMode.Normal -> timelineController.invokeOnCurrentTimeline {
body = message.markdown, sendMessage(
htmlBody = message.html, body = message.markdown,
intentionalMentions = message.intentionalMentions htmlBody = message.html,
) intentionalMentions = message.intentionalMentions
)
}
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
// First try to edit the message in the current timeline // First try to edit the message in the current timeline

View file

@ -17,10 +17,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.TextComposer

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -56,7 +57,10 @@ class PinnedMessagesListNode @AssistedInject constructor(
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
navigator = this, navigator = this,
actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor()) actionListPresenter = actionListPresenterFactory.create(
postProcessor = PinnedMessagesListTimelineActionPostProcessor(),
timelineMode = Timeline.Mode.PinnedEvents,
)
) )
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()

View file

@ -39,6 +39,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
@ -71,6 +73,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
) : Presenter<PinnedMessagesListState> { ) : Presenter<PinnedMessagesListState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -115,6 +118,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value) val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.HideThreadedEvents).collectAsState(false)
var pinnedMessageItems by remember { var pinnedMessageItems by remember {
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized) mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
} }
@ -134,6 +139,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
linkState = linkState, linkState = linkState,
displayThreadSummaries = displayThreadSummaries,
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems, timelineItems = pinnedMessageItems,
eventSink = ::handleEvents eventSink = ::handleEvents
@ -230,6 +236,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private fun pinnedMessagesListState( private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
displayThreadSummaries: Boolean,
linkState: LinkState, linkState: LinkState,
userEventPermissions: UserEventPermissions, userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>, timelineItems: AsyncData<ImmutableList<TimelineItem>>,
@ -246,6 +253,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
PinnedMessagesListState.Filled( PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
displayThreadSummaries = displayThreadSummaries,
linkState = linkState, linkState = linkState,
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data, timelineItems = timelineItems.data,

View file

@ -33,6 +33,7 @@ sealed interface PinnedMessagesListState {
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val actionListState: ActionListState, val actionListState: ActionListState,
val linkState: LinkState, val linkState: LinkState,
val displayThreadSummaries: Boolean,
val eventSink: (PinnedMessagesListEvents) -> Unit, val eventSink: (PinnedMessagesListEvents) -> Unit,
) : PinnedMessagesListState { ) : PinnedMessagesListState {
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }

View file

@ -92,6 +92,7 @@ fun aLoadedPinnedMessagesListState(
timelineItems: List<TimelineItem> = emptyList(), timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
displayThreadSummaries: Boolean = false,
eventSink: (PinnedMessagesListEvents) -> Unit = {} eventSink: (PinnedMessagesListEvents) -> Unit = {}
) = PinnedMessagesListState.Filled( ) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
@ -100,5 +101,6 @@ fun aLoadedPinnedMessagesListState(
timelineItems = timelineItems.toImmutableList(), timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState, actionListState = actionListState,
userEventPermissions = aUserEventPermissions, userEventPermissions = aUserEventPermissions,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analytics.compose.LocalAnalyticsService
@ -126,6 +127,7 @@ private fun PinnedMessagesListContent(
PinnedMessagesListState.Empty -> PinnedMessagesListEmpty() PinnedMessagesListState.Empty -> PinnedMessagesListEmpty()
is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded( is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded(
state = state, state = state,
displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick, onEventClick = onEventClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@ -163,6 +165,7 @@ private fun PinnedMessagesListEmpty(
@Composable @Composable
private fun PinnedMessagesListLoaded( private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled, state: PinnedMessagesListState.Filled,
displayThreadSummaries: Boolean,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
@ -210,6 +213,7 @@ private fun PinnedMessagesListLoaded(
) { timelineItem -> ) { timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineMode = Timeline.Mode.PinnedEvents,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false, renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
@ -222,6 +226,7 @@ private fun PinnedMessagesListLoaded(
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick, onContentClick = onEventClick,
onLongClick = ::onMessageLongClick, onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -0,0 +1,302 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.threads
import android.app.Activity
import android.content.Context
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
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.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ContributesNode(RoomScope::class)
class ThreadedMessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>()
data class Inputs(
val threadRootEventId: ThreadId,
val focusedEventId: EventId?,
) : NodeInputs
private val inputs = inputs<Inputs>()
// TODO use a loading state node to preload this instead of using `runBlocking`
private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() }
private val timelineController = TimelineController(room, threadedTimeline)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode(),
),
timelineController = timelineController,
)
interface Callback : Plugin {
fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData)
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClick(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClick()
fun onCreatePollClick()
fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId)
}
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
},
onDestroy = {
mediaPlayer.close()
}
)
}
private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false.
// - if a callback returns false, the other callback will not be invoked.
return callbacks.takeIf { it.isNotEmpty() }
?.map { it.onEventClick(timelineMode, event) }
?.all { it }
.orFalse()
}
private fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
private fun onLinkClick(
activity: Activity,
darkTheme: Boolean,
url: String,
eventSink: (TimelineEvents) -> Unit,
customTab: Boolean
) {
when (val permalink = permalinkParser.parse(url)) {
is PermalinkData.UserLink -> {
// Open the room member profile, it will fallback to
// the user profile if the user is not in the room
callbacks.forEach { it.onUserDataClick(permalink.userId) }
}
is PermalinkData.RoomLink -> {
handleRoomLinkClick(permalink, eventSink)
}
is PermalinkData.FallbackLink -> {
if (customTab) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
} else {
activity.openUrlInExternalApp(url)
}
}
is PermalinkData.RoomEmailInviteLink -> {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
}
}
private fun handleRoomLinkClick(
roomLink: PermalinkData.RoomLink,
eventSink: (TimelineEvents) -> Unit,
) {
if (room.matches(roomLink.roomIdOrAlias)) {
val eventId = roomLink.eventId
if (eventId != null) {
eventSink(TimelineEvents.FocusOnEvent(eventId))
} else {
// Click on the same room, ignore
displaySameRoomToast()
}
} else {
callbacks.forEach { it.onPermalinkClick(roomLink) }
}
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
}
override fun onForwardEventClick(eventId: EventId) {
callbacks.forEach { it.onForwardEventClick(eventId) }
}
override fun onReportContentClick(eventId: EventId, senderId: UserId) {
callbacks.forEach { it.onReportMessage(eventId, senderId) }
}
override fun onEditPollClick(eventId: EventId) {
callbacks.forEach { it.onEditPollClick(eventId) }
}
override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
callbacks.forEach { it.onPreviewAttachments(attachments) }
}
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) = Unit
private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() }
}
private fun onCreatePollClick() {
callbacks.forEach { it.onCreatePollClick() }
}
private fun onJoinCallClick() {
callbacks.forEach { it.onJoinCallClick(room.roomId) }
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
}
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val state = presenter.present()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft)
else -> Unit
}
}
MessagesView(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = {},
onEventContentClick = { isLive, event ->
if (isLive) {
onEventClick(timelineController.mainTimelineMode(), event)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
onEventClick(detachedTimelineMode, event)
} else {
false
}
}
},
onUserDataClick = this::onUserDataClick,
onLinkClick = { url, customTab ->
onLinkClick(
activity,
isDark,
url,
state.timelineState.eventSink,
customTab
)
},
onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
}
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -43,11 +44,12 @@ import javax.inject.Inject
@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class) @ContributesBinding(RoomScope::class, boundType = TimelineProvider::class)
class TimelineController @Inject constructor( class TimelineController @Inject constructor(
private val room: JoinedRoom, private val room: JoinedRoom,
@LiveTimeline private val liveTimeline: Timeline,
) : Closeable, TimelineProvider { ) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob()) private val coroutineScope = CoroutineScope(SupervisorJob())
private val liveTimeline = flowOf(room.liveTimeline) private val liveTimelineFlow = flowOf(liveTimeline)
private val detachedTimeline = MutableStateFlow<Optional<Timeline>>(Optional.empty()) private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun timelineItems(): Flow<List<MatrixTimelineItem>> { fun timelineItems(): Flow<List<MatrixTimelineItem>> {
@ -55,7 +57,13 @@ class TimelineController @Inject constructor(
} }
fun isLive(): Flow<Boolean> { fun isLive(): Flow<Boolean> {
return detachedTimeline.map { !it.isPresent } return detachedTimelineFlow.map { !it.isPresent }
}
fun mainTimelineMode(): Timeline.Mode = liveTimeline.mode
fun detachedTimelineMode(): Timeline.Mode? {
return detachedTimelineFlow.value.orElse(null)?.mode
} }
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Unit)) { suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Unit)) {
@ -72,7 +80,7 @@ class TimelineController @Inject constructor(
} }
} }
.map { newDetachedTimeline -> .map { newDetachedTimeline ->
detachedTimeline.getAndUpdate { current -> detachedTimelineFlow.getAndUpdate { current ->
if (current.isPresent) { if (current.isPresent) {
current.get().close() current.get().close()
} }
@ -90,7 +98,7 @@ class TimelineController @Inject constructor(
} }
private fun closeDetachedTimeline() { private fun closeDetachedTimeline() {
detachedTimeline.getAndUpdate { detachedTimelineFlow.getAndUpdate {
when { when {
it.isPresent -> { it.isPresent -> {
it.get().close() it.get().close()
@ -115,7 +123,7 @@ class TimelineController @Inject constructor(
} }
} }
private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached -> private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached ->
when { when {
detached.isPresent -> detached.get() detached.isPresent -> detached.get()
else -> live else -> live

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlin.time.Duration import kotlin.time.Duration
@ -31,6 +32,7 @@ sealed interface TimelineEvents {
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem
data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
data class OpenThread(val threadRootEventId: ThreadId, val focusedEvent: EventId?) : EventFromTimelineItem
/** /**
* Navigate to the predecessor or successor room of the current room. * Navigate to the predecessor or successor room of the current room.

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@ -39,6 +40,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -46,6 +49,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
@ -74,16 +78,20 @@ class TimelinePresenter @AssistedInject constructor(
private val sendPollResponseAction: SendPollResponseAction, private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction, private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController, @Assisted private val timelineController: TimelineController,
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>, private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>, private val roomCallStatePresenter: Presenter<RoomCallState>,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
private val featureFlagService: FeatureFlagService,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(navigator: MessagesNavigator): TimelinePresenter fun create(
timelineController: TimelineController,
navigator: MessagesNavigator
): TimelinePresenter
} }
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
@ -97,6 +105,9 @@ class TimelinePresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
val localScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() }
var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) } var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
@ -124,9 +135,17 @@ class TimelinePresenter @AssistedInject constructor(
timelineController.isLive() timelineController.isLive()
}.collectAsState(initial = true) }.collectAsState(initial = true)
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
}
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
is TimelineEvents.LoadMore -> { is TimelineEvents.LoadMore -> {
if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) {
// Do not paginate forwards in thread mode, as it's not supported
return
}
localScope.launch { localScope.launch {
timelineController.paginate(direction = event.direction) timelineController.paginate(direction = event.direction)
} }
@ -148,15 +167,21 @@ class TimelinePresenter @AssistedInject constructor(
} }
} }
is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch { is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute( timelineController.invokeOnCurrentTimeline {
pollStartId = event.pollStartId, sendPollResponseAction.execute(
answerId = event.answerId timeline = this,
) pollStartId = event.pollStartId,
answerId = event.answerId
)
}
} }
is TimelineEvents.EndPoll -> sessionCoroutineScope.launch { is TimelineEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute( timelineController.invokeOnCurrentTimeline {
pollStartId = event.pollStartId, endPollAction.execute(
) timeline = this,
pollStartId = event.pollStartId,
)
}
} }
is TimelineEvents.EditPoll -> { is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId) navigator.onEditPollClick(event.pollStartId)
@ -183,6 +208,12 @@ class TimelinePresenter @AssistedInject constructor(
val serverNames = calculateServerNamesForRoom(room) val serverNames = calculateServerNamesForRoom(room)
navigator.onNavigateToRoom(event.roomId, serverNames) navigator.onNavigateToRoom(event.roomId, serverNames)
} }
is TimelineEvents.OpenThread -> {
navigator.onOpenThread(
threadRootId = event.threadRootEventId,
focusedEventId = event.focusedEvent,
)
}
} }
} }
@ -270,6 +301,7 @@ class TimelinePresenter @AssistedInject constructor(
} }
return TimelineState( return TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value, newEventState = newEventState.value,
@ -277,6 +309,7 @@ class TimelinePresenter @AssistedInject constructor(
focusRequestState = focusRequestState, focusRequestState = focusRequestState,
messageShield = messageShield.value, messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -16,6 +16,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration import kotlin.time.Duration
@ -24,6 +25,7 @@ import kotlin.time.Duration
data class TimelineState( data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val timelineRoomInfo: TimelineRoomInfo, val timelineRoomInfo: TimelineRoomInfo,
val timelineMode: Timeline.Mode,
val renderReadReceipts: Boolean, val renderReadReceipts: Boolean,
val newEventState: NewEventState, val newEventState: NewEventState,
val isLive: Boolean, val isLive: Boolean,
@ -31,6 +33,7 @@ data class TimelineState(
// If not null, info will be rendered in a dialog // If not null, info will be rendered in a dialog
val messageShield: MessageShield?, val messageShield: MessageShield?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val eventSink: (TimelineEvents) -> Unit, val eventSink: (TimelineEvents) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event

View file

@ -31,6 +31,8 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId 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.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -45,12 +47,14 @@ import kotlin.random.Random
fun aTimelineState( fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(), timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
renderReadReceipts: Boolean = false, renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1, focusedEventIndex: Int = -1,
isLive: Boolean = true, isLive: Boolean = true,
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false,
eventSink: (TimelineEvents) -> Unit = {}, eventSink: (TimelineEvents) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@ -61,6 +65,7 @@ fun aTimelineState(
} }
return TimelineState( return TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = NewEventState.None, newEventState = NewEventState.None,
@ -68,6 +73,7 @@ fun aTimelineState(
focusRequestState = focusRequestState, focusRequestState = focusRequestState,
messageShield = messageShield, messageShield = messageShield,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -140,7 +146,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null, sendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false, threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(), readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
@ -166,7 +172,7 @@ internal fun aTimelineItemEvent(
groupPosition = groupPosition, groupPosition = groupPosition,
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
isThreaded = isThreaded, threadInfo = threadInfo,
origin = null, origin = null,
timelineItemDebugInfoProvider = { debugInfo }, timelineItemDebugInfoProvider = { debugInfo },
messageShieldProvider = { messageShield }, messageShieldProvider = { messageShield },

View file

@ -163,11 +163,13 @@ fun TimelineView(
) { timelineItem -> ) { timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineMode = state.timelineMode,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts, renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId, focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick, onLinkLongClick = ::onLinkLongClick,

View file

@ -13,21 +13,26 @@ import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.matrix.api.timeline.Timeline
// For previews // For previews
@Composable @Composable
internal fun ATimelineItemEventRow( internal fun ATimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false, renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false, isLastOutgoingMessage: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
displayThreadSummaries: Boolean = false,
) = TimelineItemEventRow( ) = TimelineItemEventRow(
event = event, event = event,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = {}, onEventClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {}, onLinkClick = {},

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.hideFromAccessibility
@ -72,6 +73,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -82,10 +84,17 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
@ -97,6 +106,7 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -116,10 +126,12 @@ private val BUBBLE_INCOMING_OFFSET = 16.dp
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit, onEventClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
@ -194,6 +206,7 @@ fun TimelineItemEventRow(
} }
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -227,6 +240,7 @@ fun TimelineItemEventRow(
} else { } else {
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -241,6 +255,25 @@ fun TimelineItemEventRow(
eventContentView = eventContentView, eventContentView = eventContentView,
) )
} }
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread) {
event.threadInfo.threadSummary?.let { threadSummary ->
val threadPart = stringResource(CommonStrings.common_thread)
val numberOfReplies = threadSummary.numberOfReplies.toInt().let { replies ->
pluralStringResource(CommonPlurals.common_replies, replies, replies)
}
Button(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp)
.align(if (event.isMine) Alignment.End else Alignment.Start),
text = "$threadPart - $numberOfReplies",
size = ButtonSize.Small,
onClick = {
eventSink(TimelineEvents.OpenThread(event.eventId!!.toThreadId(), null))
},
)
}
}
// Read receipts / Send state // Read receipts / Send state
TimelineItemReadReceiptView( TimelineItemReadReceiptView(
state = ReadReceiptViewState( state = ReadReceiptViewState(
@ -281,6 +314,7 @@ private fun SwipeSensitivity(
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineMode: Timeline.Mode,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
@ -360,6 +394,7 @@ private fun TimelineItemEventRowContent(
) { ) {
MessageEventBubbleContent( MessageEventBubbleContent(
event = event, event = event,
timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
onMessageLongClick = onLongClick, onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
@ -461,6 +496,7 @@ private fun MessageSenderInformation(
@Composable @Composable
private fun MessageEventBubbleContent( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineMode: Timeline.Mode,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
@ -658,7 +694,7 @@ private fun MessageEventBubbleContent(
else -> ContentPadding.Textual else -> ContentPadding.Textual
} }
CommonLayout( CommonLayout(
showThreadDecoration = event.isThreaded, showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo.threadRootId != null,
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour, paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
@ -695,3 +731,28 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
} }
} }
} }
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
groupPosition = TimelineItemGroupPosition.First,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = ThreadSummary(AsyncData.Uninitialized, numberOfReplies = 20L)
)
),
displayThreadSummaries = true,
)
}
}
}

View file

@ -17,6 +17,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
@ -56,7 +58,10 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
), ),
inReplyTo = inReplyToDetails, inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
isThreaded = true, threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = null,
),
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
) )

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -38,11 +39,13 @@ import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemGroupedEventsRow( fun TimelineItemGroupedEventsRow(
timelineItem: TimelineItem.GroupedEvents, timelineItem: TimelineItem.GroupedEvents,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
focusedEventId: EventId?, focusedEventId: EventId?,
displayThreadSummaries: Boolean,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -81,11 +84,13 @@ fun TimelineItemGroupedEventsRow(
isExpanded = isExpanded.value, isExpanded = isExpanded.value,
onExpandGroupClick = ::onExpandGroupClick, onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem, timelineItem = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
@ -107,11 +112,13 @@ private fun TimelineItemGroupedEventsRowContent(
isExpanded: Boolean, isExpanded: Boolean,
onExpandGroupClick: () -> Unit, onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents, timelineItem: TimelineItem.GroupedEvents,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -161,12 +168,14 @@ private fun TimelineItemGroupedEventsRowContent(
} }
}.forEach { subGroupEvent -> }.forEach { subGroupEvent ->
TimelineItemRow( TimelineItemRow(
timelineMode = timelineMode,
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@ -206,11 +215,13 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isExpanded = true, isExpanded = true,
onExpandGroupClick = {}, onExpandGroupClick = {},
timelineItem = events, timelineItem = events,
timelineMode = Timeline.Mode.Live,
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(), timelineProtectionState = aTimelineProtectionState(),
focusedEventId = events.events.first().eventId, focusedEventId = events.events.first().eventId,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
@ -232,11 +243,13 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isExpanded = false, isExpanded = false,
onExpandGroupClick = {}, onExpandGroupClick = {},
timelineItem = aGroupedEvents(withReadReceipts = true), timelineItem = aGroupedEvents(withReadReceipts = true),
timelineMode = Timeline.Mode.Live,
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(), timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null, focusedEventId = null,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onLinkLongClick = {}, onLinkLongClick = {},

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.libraries.ui.utils.time.isTalkbackActive
@ -53,11 +54,13 @@ import kotlin.time.DurationUnit
@Composable @Composable
internal fun TimelineItemRow( internal fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
displayThreadSummaries: Boolean,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@ -161,10 +164,12 @@ internal fun TimelineItemRow(
} }
), ),
event = timelineItem, event = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = { onContentClick(timelineItem) }, onEventClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@ -187,11 +192,13 @@ internal fun TimelineItemRow(
is TimelineItem.GroupedEvents -> { is TimelineItem.GroupedEvents -> {
TimelineItemGroupedEventsRow( TimelineItemGroupedEventsRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId, focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
onClick = onContentClick, onClick = onContentClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,

View file

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.di
import javax.inject.Qualifier
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
annotation class LiveTimeline

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
@ -67,7 +68,6 @@ class TimelineItemEventFactory @AssistedInject constructor(
url = senderProfile.getAvatarUrl(), url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender size = AvatarSize.TimelineSender
) )
currentTimelineItem.event
return TimelineItem.Event( return TimelineItem.Event(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
@ -86,7 +86,7 @@ class TimelineItemEventFactory @AssistedInject constructor(
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
isThreaded = currentTimelineItem.event.isThreaded(), threadInfo = currentTimelineItem.event.threadInfo() ?: EventThreadInfo(threadRootId = null, threadSummary = null),
origin = currentTimelineItem.event.origin, origin = currentTimelineItem.event.origin,
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider, timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider, messageShieldProvider = currentTimelineItem.event.messageShieldProvider,

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
@ -81,7 +82,7 @@ sealed interface TimelineItem {
val readReceiptState: TimelineItemReadReceipts, val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?, val inReplyTo: InReplyToDetails?,
val isThreaded: Boolean, val threadInfo: EventThreadInfo,
val origin: TimelineItemEventOrigin?, val origin: TimelineItemEventOrigin?,
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider, val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider, val messageShieldProvider: MessageShieldProvider,

View file

@ -19,10 +19,18 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -40,22 +48,29 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class VoiceMessageComposerPresenter @Inject constructor( class DefaultVoiceMessageComposerPresenter @AssistedInject constructor(
@SessionCoroutineScope @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionCoroutineScope: CoroutineScope, @Assisted private val timelineMode: Timeline.Mode,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender, mediaSenderFactory: MediaSender.Factory,
private val player: VoiceMessageComposerPlayer, private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
permissionsPresenterFactory: PermissionsPresenter.Factory permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<VoiceMessageComposerState> { ) : VoiceMessageComposerPresenter {
@ContributesBinding(RoomScope::class)
@AssistedFactory
interface Factory : VoiceMessageComposerPresenter.Factory {
override fun create(timelineMode: Timeline.Mode): DefaultVoiceMessageComposerPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable @Composable
override fun present(): VoiceMessageComposerState { override fun present(): VoiceMessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@ -21,7 +22,8 @@ class FakeMessagesNavigator(
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>) -> Unit = { _ -> lambdaError() },
private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() } private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
) : MessagesNavigator { ) : MessagesNavigator {
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda(eventId, debugInfo) onShowEventDebugInfoClickLambda(eventId, debugInfo)
@ -46,4 +48,8 @@ class FakeMessagesNavigator(
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) { override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) {
onNavigateToRoomLambda(roomId, serverNames) onNavigateToRoomLambda(roomId, serverNames)
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
onOpenThreadLambda(threadRootId, focusedEventId)
}
} }

View file

@ -34,8 +34,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.messages.test.timeline.voicemessages.composer.FakeDefaultVoiceMessageComposerPresenterFactory
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@ -908,7 +909,10 @@ class MessagesPresenterTest {
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
val presenter = createMessagesPresenter(joinedRoom = room, analyticsService = analyticsService) val presenter = createMessagesPresenter(
joinedRoom = room,
analyticsService = analyticsService,
)
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
content = aTimelineItemTextContent() content = aTimelineItemTextContent()
@ -1047,6 +1051,7 @@ class MessagesPresenterTest {
) )
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
joinedRoom = room, joinedRoom = room,
timeline = timeline,
) )
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
skipItems(1) skipItems(1)
@ -1168,6 +1173,7 @@ class MessagesPresenterTest {
liveTimeline = FakeTimeline(), liveTimeline = FakeTimeline(),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
), ),
timeline: Timeline = joinedRoom.liveTimeline,
navigator: FakeMessagesNavigator = FakeMessagesNavigator(), navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
@ -1188,7 +1194,7 @@ class MessagesPresenterTest {
return MessagesPresenter( return MessagesPresenter(
room = joinedRoom, room = joinedRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = { aVoiceMessageComposerState() }, voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() }, timelineProtectionPresenter = { aTimelineProtectionState() },
actionListPresenter = { anActionListState(eventSink = actionListEventSink) }, actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
@ -1207,7 +1213,7 @@ class MessagesPresenterTest {
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
timelineController = TimelineController(joinedRoom), timelineController = TimelineController(joinedRoom, timeline),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
encryptionService = encryptionService, encryptionService = encryptionService,
analyticsService = analyticsService, analyticsService = analyticsService,

View file

@ -28,10 +28,13 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
@ -192,7 +195,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false, isEditable = false,
isThreaded = true, threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -426,7 +429,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
isThreaded = true, threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -1240,11 +1243,59 @@ class ActionListPresenterTest {
assertThat(target.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(userDisplayName = "Alice")) assertThat(target.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(userDisplayName = "Alice"))
} }
} }
@Test
fun `present - compute for threaded timeline`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false, timelineMode = Timeline.Mode.Thread(A_THREAD_ID))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemVoiceContent(
caption = null,
),
threadInfo = EventThreadInfo(A_THREAD_ID, null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true
)
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
// This is Reply, not ReplyInThread
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
)
}
}
} }
private fun createActionListPresenter( private fun createActionListPresenter(
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,
room: BaseRoom = FakeBaseRoom(), room: BaseRoom = FakeBaseRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
): ActionListPresenter { ): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
return DefaultActionListPresenter( return DefaultActionListPresenter(
@ -1253,5 +1304,6 @@ private fun createActionListPresenter(
room = room, room = room,
userSendFailureFactory = VerifiedUserSendFailureFactory(room), userSendFailureFactory = VerifiedUserSendFailureFactory(room),
dateFormatter = FakeDateFormatter(), dateFormatter = FakeDateFormatter(),
timelineMode = timelineMode,
) )
} }

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@ -573,6 +574,7 @@ class AttachmentsPreviewPresenterTest {
uri = mockMediaUrl, uri = mockMediaUrl,
), ),
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
@ -595,14 +597,24 @@ class AttachmentsPreviewPresenterTest {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
mediaSender = MediaSender(mediaPreProcessor, room, { mediaSenderFactory = object : MediaSender.Factory {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) override fun create(timelineMode: Timeline.Mode): MediaSender {
}), return MediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}
)
}
},
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter, temporaryUriDeleter = temporaryUriDeleter,
sessionCoroutineScope = this, sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
timelineMode = timelineMode,
) )
} }

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
@ -40,7 +41,7 @@ internal fun aMessageEvent(
canBeRepliedTo: Boolean = true, canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false, threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },
@ -61,7 +62,7 @@ internal fun aMessageEvent(
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
isThreaded = isThreaded, threadInfo = threadInfo,
origin = null, origin = null,
timelineItemDebugInfoProvider = debugInfoProvider, timelineItemDebugInfoProvider = debugInfoProvider,
messageShieldProvider = messageShieldProvider, messageShieldProvider = messageShieldProvider,

View file

@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -1521,6 +1522,7 @@ class MessageComposerPresenterTest {
room: JoinedRoom = FakeJoinedRoom( room: JoinedRoom = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
), ),
timeline: Timeline = room.liveTimeline,
navigator: MessagesNavigator = FakeMessagesNavigator(), navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this@MessageComposerPresenterTest.pickerProvider, pickerProvider: PickerProvider = this@MessageComposerPresenterTest.pickerProvider,
locationService: LocationService = FakeLocationService(true), locationService: LocationService = FakeLocationService(true),
@ -1546,11 +1548,21 @@ class MessageComposerPresenterTest {
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaSender = MediaSender( mediaSenderFactory = object : MediaSender.Factory {
preProcessor = mediaPreProcessor, override fun create(timelineMode: Timeline.Mode): MediaSender {
room = room, return MediaSender(
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) } preProcessor = mediaPreProcessor,
), room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD
)
}
)
}
},
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService, analyticsService = analyticsService,
locationService = locationService, locationService = locationService,
@ -1560,7 +1572,7 @@ class MessageComposerPresenterTest {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room), timelineController = TimelineController(room, timeline),
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,

View file

@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProv
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
@ -297,6 +298,7 @@ class PinnedMessagesListPresenterTest {
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
syncService: SyncService = FakeSyncService(), syncService: SyncService = FakeSyncService(),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
): PinnedMessagesListPresenter { ): PinnedMessagesListPresenter {
val timelineProvider = PinnedEventsTimelineProvider( val timelineProvider = PinnedEventsTimelineProvider(
room = room, room = room,
@ -314,6 +316,7 @@ class PinnedMessagesListPresenterTest {
actionListPresenter = { anActionListState() }, actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() }, linkPresenter = { aLinkState() },
analyticsService = analyticsService, analyticsService = analyticsService,
featureFlagService = featureFlagService,
sessionCoroutineScope = this, sessionCoroutineScope = this,
) )
} }

View file

@ -33,7 +33,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@ -72,7 +72,7 @@ class TimelineControllerTest {
} }
} }
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(joinedRoom, liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@ -100,7 +100,7 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline liveTimeline = liveTimeline
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
@ -119,7 +119,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
@ -147,7 +147,7 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline liveTimeline = liveTimeline
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
assertThat(sut.timelineItems().first()).hasSize(1) assertThat(sut.timelineItems().first()).hasSize(1)
} }
@ -169,7 +169,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID)
awaitItem().also { state -> awaitItem().also { state ->
@ -194,7 +194,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(joinedRoom) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->

View file

@ -28,6 +28,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
@ -787,6 +788,7 @@ class TimelinePresenterTest {
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@ -799,11 +801,12 @@ class TimelinePresenterTest {
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer, timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room), timelineController = TimelineController(room, timeline),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
markAsFullyRead = markAsFullyRead, markAsFullyRead = markAsFullyRead,
featureFlagService = featureFlagService,
) )
} }
} }

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -749,14 +750,14 @@ class TimelineItemContentMessageFactoryTest {
body: String = "Body", body: String = "Body",
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
isEdited: Boolean = false, isEdited: Boolean = false,
isThreaded: Boolean = false, threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
type: MessageType, type: MessageType,
): MessageContent { ): MessageContent {
return MessageContent( return MessageContent(
body = body, body = body,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
isEdited = isEdited, isEdited = isEdited,
isThreaded = isThreaded, threadInfo = threadInfo,
type = type, type = type,
) )
} }

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -41,7 +42,7 @@ class TimelineItemGrouperTest {
isEditable = false, isEditable = false,
canBeRepliedTo = false, canBeRepliedTo = false,
inReplyTo = null, inReplyTo = null,
isThreaded = false, threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
origin = null, origin = null,
timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() }, timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() },
messageShieldProvider = { null }, messageShieldProvider = { null },

View file

@ -17,10 +17,13 @@ import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aReplyMode import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@ -75,6 +78,7 @@ class VoiceMessageComposerPresenterTest {
private val mediaSender = MediaSender( private val mediaSender = MediaSender(
preProcessor = mediaPreProcessor, preProcessor = mediaPreProcessor,
room = joinedRoom, room = joinedRoom,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }, mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) },
) )
private val messageComposerContext = FakeMessageComposerContext() private val messageComposerContext = FakeMessageComposerContext()
@ -86,7 +90,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -100,7 +104,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - recording state`() = runTest { fun `present - recording state`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -116,7 +120,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - recording keeps screen on`() = runTest { fun `present - recording keeps screen on`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -140,7 +144,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - abort recording`() = runTest { fun `present - abort recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -155,7 +159,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - finish recording`() = runTest { fun `present - finish recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -172,7 +176,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - play recording before it is ready`() = runTest { fun `present - play recording before it is ready`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -191,7 +195,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - play recording`() = runTest { fun `present - play recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -209,7 +213,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - pause recording`() = runTest { fun `present - pause recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -228,7 +232,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - seek recording`() = runTest { fun `present - seek recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -255,7 +259,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - delete recording`() = runTest { fun `present - delete recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -273,7 +277,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - delete while playing`() = runTest { fun `present - delete while playing`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -295,7 +299,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send recording`() = runTest { fun `present - send recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -314,7 +318,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - sending is tracked`() = runTest { fun `present - sending is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -343,7 +347,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send while playing`() = runTest { fun `present - send while playing`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -365,7 +369,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send recording before previous completed, waits`() = runTest { fun `present - send recording before previous completed, waits`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -390,7 +394,7 @@ class VoiceMessageComposerPresenterTest {
fun `present - send failures aren't tracked`() = runTest { fun `present - send failures aren't tracked`() = runTest {
// Let sending fail due to media preprocessing error // Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception())) mediaPreProcessor.givenResult(Result.failure(Exception()))
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -414,7 +418,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send failures can be retried`() = runTest { fun `present - send failures can be retried`() = runTest {
// Let sending fail due to media preprocessing error // Let sending fail due to media preprocessing error
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -443,7 +447,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send failures are displayed as an error dialog`() = runTest { fun `present - send failures are displayed as an error dialog`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -478,7 +482,7 @@ class VoiceMessageComposerPresenterTest {
@Test @Test
fun `present - send error - missing recording is tracked`() = runTest { fun `present - send error - missing recording is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -499,7 +503,7 @@ class VoiceMessageComposerPresenterTest {
fun `present - record error - security exceptions are tracked`() = runTest { fun `present - record error - security exceptions are tracked`() = runTest {
val exception = SecurityException("") val exception = SecurityException("")
voiceRecorder.givenThrowsSecurityException(exception) voiceRecorder.givenThrowsSecurityException(exception)
val presenter = createVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -521,7 +525,7 @@ class VoiceMessageComposerPresenterTest {
val permissionsPresenter = createFakePermissionsPresenter( val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false, recordPermissionGranted = false,
) )
val presenter = createVoiceMessageComposerPresenter( val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -550,7 +554,7 @@ class VoiceMessageComposerPresenterTest {
val permissionsPresenter = createFakePermissionsPresenter( val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false, recordPermissionGranted = false,
) )
val presenter = createVoiceMessageComposerPresenter( val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -584,7 +588,7 @@ class VoiceMessageComposerPresenterTest {
val permissionsPresenter = createFakePermissionsPresenter( val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false, recordPermissionGranted = false,
) )
val presenter = createVoiceMessageComposerPresenter( val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -656,17 +660,22 @@ class VoiceMessageComposerPresenterTest {
} }
} }
private fun TestScope.createVoiceMessageComposerPresenter( private fun TestScope.createDefaultVoiceMessageComposerPresenter(
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
): VoiceMessageComposerPresenter { ): DefaultVoiceMessageComposerPresenter {
return VoiceMessageComposerPresenter( return DefaultVoiceMessageComposerPresenter(
backgroundScope, sessionCoroutineScope = backgroundScope,
voiceRecorder, timelineMode = Timeline.Mode.Live,
analyticsService, voiceRecorder = voiceRecorder,
mediaSender, analyticsService = analyticsService,
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext, messageComposerContext = messageComposerContext,
FakePermissionsPresenterFactory(permissionsPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
) )
} }

View file

@ -15,7 +15,12 @@ android {
dependencies { dependencies {
api(projects.features.messages.impl) api(projects.features.messages.impl)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.test)
implementation(projects.libraries.mediaplayer.test)
implementation(projects.libraries.mediaupload.test)
implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.permissions.test)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.voicerecorder.test)
implementation(projects.services.analytics.test)
} }

View file

@ -0,0 +1,52 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.test.timeline.voicemessages.composer
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.CoroutineScope
class FakeDefaultVoiceMessageComposerPresenterFactory(
private val sessionCoroutineScope: CoroutineScope,
private val mediaSender: MediaSender = MediaSender(
preProcessor = FakeMediaPreProcessor(),
room = FakeJoinedRoom(),
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
),
) : DefaultVoiceMessageComposerPresenter.Factory {
override fun create(timelineMode: Timeline.Mode): DefaultVoiceMessageComposerPresenter {
return DefaultVoiceMessageComposerPresenter(
sessionCoroutineScope = sessionCoroutineScope,
timelineMode = timelineMode,
voiceRecorder = FakeVoiceRecorder(),
analyticsService = FakeAnalyticsService(),
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
player = VoiceMessageComposerPlayer(
mediaPlayer = FakeMediaPlayer(),
sessionCoroutineScope = sessionCoroutineScope,
),
messageComposerContext = FakeMessageComposerContext(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
}

View file

@ -8,7 +8,8 @@
package io.element.android.features.poll.api.actions package io.element.android.features.poll.api.actions
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
interface EndPollAction { interface EndPollAction {
suspend fun execute(pollStartId: EventId): Result<Unit> suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit>
} }

View file

@ -8,7 +8,12 @@
package io.element.android.features.poll.api.actions package io.element.android.features.poll.api.actions
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
interface SendPollResponseAction { interface SendPollResponseAction {
suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> suspend fun execute(
timeline: Timeline,
pollStartId: EventId,
answerId: String
): Result<Unit>
} }

View file

@ -10,9 +10,11 @@ package io.element.android.features.poll.api.create
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.timeline.Timeline
interface CreatePollEntryPoint : FeatureEntryPoint { interface CreatePollEntryPoint : FeatureEntryPoint {
data class Params( data class Params(
val timelineMode: Timeline.Mode,
val mode: CreatePollMode, val mode: CreatePollMode,
) )

View file

@ -12,17 +12,16 @@ import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultEndPollAction @Inject constructor( class DefaultEndPollAction @Inject constructor(
private val room: JoinedRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : EndPollAction { ) : EndPollAction {
override suspend fun execute(pollStartId: EventId): Result<Unit> { override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
return room.liveTimeline.endPoll( return timeline.endPoll(
pollStartId = pollStartId, pollStartId = pollStartId,
text = "The poll with event id: $pollStartId has ended." text = "The poll with event id: $pollStartId has ended."
).onSuccess { ).onSuccess {

View file

@ -12,17 +12,16 @@ import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultSendPollResponseAction @Inject constructor( class DefaultSendPollResponseAction @Inject constructor(
private val room: JoinedRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : SendPollResponseAction { ) : SendPollResponseAction {
override suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> { override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
return room.liveTimeline.sendPollResponse( return timeline.sendPollResponse(
pollStartId = pollStartId, pollStartId = pollStartId,
answers = listOf(answerId), answers = listOf(answerId),
).onSuccess { ).onSuccess {

View file

@ -21,6 +21,7 @@ import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -31,7 +32,7 @@ class CreatePollNode @AssistedInject constructor(
presenterFactory: CreatePollPresenter.Factory, presenterFactory: CreatePollPresenter.Factory,
analyticsService: AnalyticsService, analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs(val mode: CreatePollMode) : NodeInputs data class Inputs(val mode: CreatePollMode, val timelineMode: Timeline.Mode) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -44,6 +45,7 @@ class CreatePollNode @AssistedInject constructor(
} }
}, },
mode = inputs.mode, mode = inputs.mode,
timelineMode = inputs.timelineMode,
) )
init { init {

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.poll.isDisclosed import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -37,17 +38,24 @@ import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
class CreatePollPresenter @AssistedInject constructor( class CreatePollPresenter @AssistedInject constructor(
private val repository: PollRepository, repositoryFactory: PollRepository.Factory,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit, @Assisted private val navigateUp: () -> Unit,
@Assisted private val mode: CreatePollMode, @Assisted private val mode: CreatePollMode,
@Assisted private val timelineMode: Timeline.Mode,
) : Presenter<CreatePollState> { ) : Presenter<CreatePollState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(backNavigator: () -> Unit, mode: CreatePollMode): CreatePollPresenter fun create(
timelineMode: Timeline.Mode,
backNavigator: () -> Unit,
mode: CreatePollMode
): CreatePollPresenter
} }
private val repository = repositoryFactory.create(timelineMode)
@Composable @Composable
override fun present(): CreatePollState { override fun present(): CreatePollState {
// The initial state of the form. In edit mode this will be populated with the poll being edited. // The initial state of the form. In edit mode this will be populated with the poll being edited.

View file

@ -23,7 +23,7 @@ class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
return object : CreatePollEntryPoint.NodeBuilder { return object : CreatePollEntryPoint.NodeBuilder {
override fun params(params: CreatePollEntryPoint.Params): CreatePollEntryPoint.NodeBuilder { override fun params(params: CreatePollEntryPoint.Params): CreatePollEntryPoint.NodeBuilder {
plugins += CreatePollNode.Inputs(mode = params.mode) plugins += CreatePollNode.Inputs(timelineMode = params.timelineMode, mode = params.mode)
return this return this
} }

View file

@ -7,24 +7,40 @@
package io.element.android.features.poll.impl.data package io.element.android.features.poll.impl.data
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import javax.inject.Inject
class PollRepository @Inject constructor( class PollRepository @AssistedInject constructor(
private val room: JoinedRoom, private val room: JoinedRoom,
private val timelineProvider: TimelineProvider, private val defaultTimelineProvider: TimelineProvider,
@Assisted private val timelineMode: Timeline.Mode,
) { ) {
@AssistedFactory
interface Factory {
fun create(
timelineMode: Timeline.Mode,
): PollRepository
}
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatchingExceptions { suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatchingExceptions {
timelineProvider getTimelineProvider()
.getOrThrow()
.getActiveTimeline() .getActiveTimeline()
.timelineItems .timelineItems
.first() .first()
@ -42,30 +58,51 @@ class PollRepository @Inject constructor(
pollKind: PollKind, pollKind: PollKind,
maxSelections: Int, maxSelections: Int,
): Result<Unit> = when (existingPollId) { ): Result<Unit> = when (existingPollId) {
null -> room.liveTimeline.createPoll( null -> getTimelineProvider().flatMap { timelineProvider ->
question = question, timelineProvider
answers = answers, .getActiveTimeline()
maxSelections = maxSelections, .createPoll(
pollKind = pollKind, question = question,
) answers = answers,
else -> timelineProvider maxSelections = maxSelections,
.getActiveTimeline() pollKind = pollKind,
.editPoll( )
pollStartId = existingPollId, }
question = question, else -> getTimelineProvider().flatMap { timelineProvider ->
answers = answers, timelineProvider.getActiveTimeline()
maxSelections = maxSelections, .editPoll(
pollKind = pollKind, pollStartId = existingPollId,
) question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
} }
suspend fun deletePoll( suspend fun deletePoll(
pollStartId: EventId, pollStartId: EventId,
): Result<Unit> = ): Result<Unit> =
timelineProvider getTimelineProvider().flatMap { timelineProvider ->
.getActiveTimeline() timelineProvider.getActiveTimeline()
.redactEvent( .redactEvent(
eventOrTransactionId = pollStartId.toEventOrTransactionId(), eventOrTransactionId = pollStartId.toEventOrTransactionId(),
reason = null, reason = null,
) )
}
private suspend fun getTimelineProvider(): Result<TimelineProvider> {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
threadedTimelineResult.map { threadedTimeline ->
object : TimelineProvider {
private val flow = MutableStateFlow<Timeline?>(threadedTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline?> = flow
}
}
}
else -> Result.success(defaultTimelineProvider)
}
}
} }

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -52,7 +53,12 @@ class PollHistoryFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
is NavTarget.EditPoll -> { is NavTarget.EditPoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext) createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId))) .params(
CreatePollEntryPoint.Params(
timelineMode = Timeline.Mode.Live,
mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId)
)
)
.build() .build()
} }
NavTarget.Root -> { NavTarget.Root -> {

View file

@ -67,10 +67,14 @@ class PollHistoryPresenter @Inject constructor(
coroutineScope.loadMore(timeline) coroutineScope.loadMore(timeline)
} }
is PollHistoryEvents.SelectPollAnswer -> sessionCoroutineScope.launch { is PollHistoryEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute(pollStartId = event.pollStartId, answerId = event.answerId) sendPollResponseAction.execute(
timeline = timeline,
pollStartId = event.pollStartId,
answerId = event.answerId
)
} }
is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch { is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute(pollStartId = event.pollStartId) endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
} }
is PollHistoryEvents.SelectFilter -> { is PollHistoryEvents.SelectFilter -> {
activeFilter = event.filter activeFilter = event.filter

View file

@ -21,6 +21,7 @@ import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.data.PollRepository import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@ -551,12 +552,18 @@ class CreatePollPresenterTest {
private fun createCreatePollPresenter( private fun createCreatePollPresenter(
mode: CreatePollMode = CreatePollMode.NewPoll, mode: CreatePollMode = CreatePollMode.NewPoll,
room: FakeJoinedRoom = fakeJoinedRoom, room: FakeJoinedRoom = fakeJoinedRoom,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
): CreatePollPresenter = CreatePollPresenter( ): CreatePollPresenter = CreatePollPresenter(
repository = PollRepository(room, LiveTimelineProvider(room)), repositoryFactory = object : PollRepository.Factory {
override fun create(timelineMode: Timeline.Mode): PollRepository {
return PollRepository(room, LiveTimelineProvider(room), timelineMode)
}
},
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext, messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ }, navigateUp = { navUpInvocationsCount++ },
mode = mode, mode = mode,
timelineMode = timelineMode,
) )
} }

View file

@ -9,6 +9,7 @@ package io.element.android.features.poll.test.actions
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
class FakeEndPollAction : EndPollAction { class FakeEndPollAction : EndPollAction {
private var executionCount = 0 private var executionCount = 0
@ -17,7 +18,7 @@ class FakeEndPollAction : EndPollAction {
assert(executionCount == count) assert(executionCount == count)
} }
override suspend fun execute(pollStartId: EventId): Result<Unit> { override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
executionCount++ executionCount++
return Result.success(Unit) return Result.success(Unit)
} }

View file

@ -9,6 +9,7 @@ package io.element.android.features.poll.test.actions
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
class FakeSendPollResponseAction : SendPollResponseAction { class FakeSendPollResponseAction : SendPollResponseAction {
private var executionCount = 0 private var executionCount = 0
@ -17,7 +18,7 @@ class FakeSendPollResponseAction : SendPollResponseAction {
assert(executionCount == count) assert(executionCount == count)
} }
override suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> { override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
executionCount++ executionCount++
return Result.success(Unit) return Result.success(Unit)
} }

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -88,6 +89,7 @@ class SharePresenter @AssistedInject constructor(
val mediaSender = MediaSender( val mediaSender = MediaSender(
preProcessor = mediaPreProcessor, preProcessor = mediaPreProcessor,
room = room, room = room,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
) )
filesToShare filesToShare

View file

@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -174,7 +175,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
) { ) {
val body = "Shared body" val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent { fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, false, type) return MessageContent(body, null, false, EventThreadInfo(null, null), type)
} }
val sharedContentMessagesTypes = arrayOf( val sharedContentMessagesTypes = arrayOf(

View file

@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -129,7 +130,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
fun `Message contents`() { fun `Message contents`() {
val body = "Shared body" val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent { fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, false, type) return MessageContent(body, null, false, EventThreadInfo(null, null), type)
} }
val sharedContentMessagesTypes = arrayOf( val sharedContentMessagesTypes = arrayOf(

View file

@ -93,4 +93,11 @@ enum class FeatureFlags(
// False so it's displayed in the developer options screen // False so it's displayed in the developer options screen
isFinished = false, isFinished = false,
), ),
HideThreadedEvents(
key = "feature.thread_timeline",
title = "Threads",
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
defaultValue = { false },
isFinished = false,
)
} }

View file

@ -20,3 +20,5 @@ value class EventId(val value: String) : Serializable {
override fun toString(): String = value override fun toString(): String = value
} }
fun EventId.toThreadId(): ThreadId = ThreadId(value)

View file

@ -8,10 +8,12 @@
package io.element.android.libraries.matrix.api.room package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
sealed interface CreateTimelineParams { sealed interface CreateTimelineParams {
data class Focused(val focusedEventId: EventId) : CreateTimelineParams data class Focused(val focusedEventId: EventId) : CreateTimelineParams
data object MediaOnly : CreateTimelineParams data object MediaOnly : CreateTimelineParams
data class MediaOnlyFocused(val focusedEventId: EventId) : CreateTimelineParams data class MediaOnlyFocused(val focusedEventId: EventId) : CreateTimelineParams
data object PinnedOnly : CreateTimelineParams data object PinnedOnly : CreateTimelineParams
data class Threaded(val threadRootEventId: ThreadId) : CreateTimelineParams
} }

View file

@ -7,8 +7,10 @@
package io.element.android.libraries.matrix.api.timeline package io.element.android.libraries.matrix.api.timeline
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId 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.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
@ -23,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
interface Timeline : AutoCloseable { interface Timeline : AutoCloseable {
@ -38,13 +41,16 @@ interface Timeline : AutoCloseable {
FORWARDS FORWARDS
} }
enum class Mode { @Parcelize
LIVE, sealed interface Mode : Parcelable {
FOCUSED_ON_EVENT, data object Live : Mode
PINNED_EVENTS, data class FocusedOnEvent(val eventId: EventId) : Mode
MEDIA, data object PinnedEvents : Mode
data object Media : Mode
data class Thread(val threadRootId: ThreadId) : Mode
} }
val mode: Mode
val membershipChangeEventReceived: Flow<Unit> val membershipChangeEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean> suspend fun paginate(direction: PaginationDirection): Result<Boolean>

View file

@ -0,0 +1,33 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.timeline.item
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
data class EventThreadInfo(
val threadRootId: ThreadId?,
val threadSummary: ThreadSummary?,
)
data class ThreadSummary(
val latestEvent: AsyncData<EmbeddedEventInfo>,
val numberOfReplies: Long,
)
data class EmbeddedEventInfo(
val eventOrTransactionId: EventOrTransactionId,
val content: EventContent,
val senderId: UserId,
val senderProfile: ProfileTimelineDetails,
val timestamp: Long,
)

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
@ -23,7 +24,7 @@ data class MessageContent(
val body: String, val body: String,
val inReplyTo: InReplyTo?, val inReplyTo: InReplyTo?,
val isEdited: Boolean, val isEdited: Boolean,
val isThreaded: Boolean, val threadInfo: EventThreadInfo,
val type: MessageType val type: MessageType
) : EventContent ) : EventContent

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SendHandle import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -37,9 +38,7 @@ data class EventTimelineItem(
return (content as? MessageContent)?.inReplyTo return (content as? MessageContent)?.inReplyTo
} }
fun isThreaded(): Boolean { fun threadInfo(): EventThreadInfo? = (content as? MessageContent)?.threadInfo
return (content as? MessageContent)?.isThreaded ?: false
}
fun hasNotLoadedInReplyTo(): Boolean { fun hasNotLoadedInReplyTo(): Boolean {
val details = inReplyTo() val details = inReplyTo()

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
@ -133,6 +134,7 @@ class RustMatrixClient(
baseCacheDirectory: File, baseCacheDirectory: File,
clock: SystemClock, clock: SystemClock,
timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService,
) : MatrixClient { ) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId()) override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
@ -203,6 +205,7 @@ class RustMatrixClient(
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
roomInfoMapper = roomInfoMapper, roomInfoMapper = roomInfoMapper,
featureFlagService = featureFlagService,
) )
override val mediaLoader: MatrixMediaLoader = RustMediaLoader( override val mediaLoader: MatrixMediaLoader = RustMediaLoader(

View file

@ -93,6 +93,7 @@ class RustMatrixClientFactory @Inject constructor(
baseCacheDirectory = cacheDirectory, baseCacheDirectory = cacheDirectory,
clock = clock, clock = clock,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
featureFlagService = featureFlagService,
).also { ).also {
Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
} }
@ -131,6 +132,7 @@ class RustMatrixClientFactory @Inject constructor(
) )
) )
.enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite)) .enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite))
.threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents), threadSubscriptions = false)
.run { .run {
// Apply sliding sync version settings // Apply sliding sync version settings
when (slidingSyncType) { when (slidingSyncType) {

View file

@ -11,6 +11,8 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
@ -83,6 +85,7 @@ class JoinedRustRoom(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock, private val systemClock: SystemClock,
private val roomContentForwarder: RoomContentForwarder, private val roomContentForwarder: RoomContentForwarder,
private val featureFlagService: FeatureFlagService,
) : JoinedRoom, BaseRoom by baseRoom { ) : JoinedRoom, BaseRoom by baseRoom {
// Create a dispatcher for all room methods... // Create a dispatcher for all room methods...
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
@ -132,7 +135,7 @@ class JoinedRustRoom(
override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(RoomNotificationSettingsState.Unknown) override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(RoomNotificationSettingsState.Unknown)
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.LIVE) { override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) {
syncUpdateFlow.value = systemClock.epochMillis() syncUpdateFlow.value = systemClock.epochMillis()
} }
@ -153,22 +156,27 @@ class JoinedRustRoom(
override suspend fun createTimeline( override suspend fun createTimeline(
createTimelineParams: CreateTimelineParams, createTimelineParams: CreateTimelineParams,
): Result<Timeline> = withContext(roomDispatcher) { ): Result<Timeline> = withContext(roomDispatcher) {
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
val focus = when (createTimelineParams) { val focus = when (createTimelineParams) {
is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents( is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents(
maxEventsToLoad = 100u, maxEventsToLoad = 100u,
maxConcurrentRequests = 10u, maxConcurrentRequests = 10u,
) )
is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = false) is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents)
is CreateTimelineParams.Focused -> TimelineFocus.Event( is CreateTimelineParams.Focused -> TimelineFocus.Event(
eventId = createTimelineParams.focusedEventId.value, eventId = createTimelineParams.focusedEventId.value,
numContextEvents = 50u, numContextEvents = 50u,
hideThreadedEvents = false, hideThreadedEvents = hideThreadedEvents,
) )
is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event( is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event(
eventId = createTimelineParams.focusedEventId.value, eventId = createTimelineParams.focusedEventId.value,
numContextEvents = 50u, numContextEvents = 50u,
// Never hide threaded events in media focused timeline
hideThreadedEvents = false, hideThreadedEvents = false,
) )
is CreateTimelineParams.Threaded -> TimelineFocus.Thread(
rootEventId = createTimelineParams.threadRootEventId.value,
)
} }
val filter = when (createTimelineParams) { val filter = when (createTimelineParams) {
@ -182,7 +190,8 @@ class JoinedRustRoom(
) )
) )
is CreateTimelineParams.Focused, is CreateTimelineParams.Focused,
CreateTimelineParams.PinnedOnly -> TimelineFilter.All CreateTimelineParams.PinnedOnly,
is CreateTimelineParams.Threaded -> TimelineFilter.All
} }
val internalIdPrefix = when (createTimelineParams) { val internalIdPrefix = when (createTimelineParams) {
@ -190,6 +199,7 @@ class JoinedRustRoom(
is CreateTimelineParams.Focused -> "focus_${createTimelineParams.focusedEventId}" is CreateTimelineParams.Focused -> "focus_${createTimelineParams.focusedEventId}"
is CreateTimelineParams.MediaOnly -> "MediaGallery_" is CreateTimelineParams.MediaOnly -> "MediaGallery_"
is CreateTimelineParams.MediaOnlyFocused -> "MediaGallery_${createTimelineParams.focusedEventId}" is CreateTimelineParams.MediaOnlyFocused -> "MediaGallery_${createTimelineParams.focusedEventId}"
is CreateTimelineParams.Threaded -> "Thread_${createTimelineParams.threadRootEventId}"
} }
// Note that for TimelineFilter.MediaOnlyFocused, the date separator will be filtered out, // Note that for TimelineFilter.MediaOnlyFocused, the date separator will be filtered out,
@ -198,7 +208,8 @@ class JoinedRustRoom(
is CreateTimelineParams.MediaOnly, is CreateTimelineParams.MediaOnly,
is CreateTimelineParams.MediaOnlyFocused -> DateDividerMode.MONTHLY is CreateTimelineParams.MediaOnlyFocused -> DateDividerMode.MONTHLY
is CreateTimelineParams.Focused, is CreateTimelineParams.Focused,
CreateTimelineParams.PinnedOnly -> DateDividerMode.DAILY CreateTimelineParams.PinnedOnly,
is CreateTimelineParams.Threaded -> DateDividerMode.DAILY
} }
// Track read receipts only for focused timeline for performance optimization // Track read receipts only for focused timeline for performance optimization
@ -216,17 +227,19 @@ class JoinedRustRoom(
) )
).let { innerTimeline -> ).let { innerTimeline ->
val mode = when (createTimelineParams) { val mode = when (createTimelineParams) {
is CreateTimelineParams.Focused -> Timeline.Mode.FOCUSED_ON_EVENT is CreateTimelineParams.Focused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId)
is CreateTimelineParams.MediaOnly -> Timeline.Mode.MEDIA is CreateTimelineParams.MediaOnly -> Timeline.Mode.Media
is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FOCUSED_ON_EVENT is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId)
CreateTimelineParams.PinnedOnly -> Timeline.Mode.PINNED_EVENTS CreateTimelineParams.PinnedOnly -> Timeline.Mode.PinnedEvents
is CreateTimelineParams.Threaded -> Timeline.Mode.Thread(createTimelineParams.threadRootEventId)
} }
innerTimeline.map(mode = mode) innerTimeline.map(mode = mode)
} }
}.mapFailure { }.mapFailure {
when (createTimelineParams) { when (createTimelineParams) {
is CreateTimelineParams.Focused, is CreateTimelineParams.Focused,
is CreateTimelineParams.MediaOnlyFocused -> it.toFocusEventException() is CreateTimelineParams.MediaOnlyFocused,
is CreateTimelineParams.Threaded -> it.toFocusEventException()
CreateTimelineParams.MediaOnly, CreateTimelineParams.MediaOnly,
CreateTimelineParams.PinnedOnly -> it CreateTimelineParams.PinnedOnly -> it
} }

View file

@ -9,6 +9,8 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.appconfig.TimelineConfig import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -48,6 +50,7 @@ class RustRoomFactory(
private val innerRoomListService: InnerRoomListService, private val innerRoomListService: InnerRoomListService,
private val roomSyncSubscriber: RoomSyncSubscriber, private val roomSyncSubscriber: RoomSyncSubscriber,
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val roomInfoMapper: RoomInfoMapper, private val roomInfoMapper: RoomInfoMapper,
) { ) {
@ -105,10 +108,11 @@ class RustRoomFactory(
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
if (sdkRoom.membership() == Membership.JOINED) { if (sdkRoom.membership() == Membership.JOINED) {
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
// Init the live timeline in the SDK from the Room // Init the live timeline in the SDK from the Room
val timeline = sdkRoom.timelineWithConfiguration( val timeline = sdkRoom.timelineWithConfiguration(
TimelineConfiguration( TimelineConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = false), focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
internalIdPrefix = "live", internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY, dateDividerMode = DateDividerMode.DAILY,
@ -125,6 +129,7 @@ class RustRoomFactory(
liveInnerTimeline = timeline, liveInnerTimeline = timeline,
coroutineDispatchers = dispatchers, coroutineDispatchers = dispatchers,
systemClock = systemClock, systemClock = systemClock,
featureFlagService = featureFlagService,
) )
) )
} else { } else {

View file

@ -79,7 +79,7 @@ private const val PAGINATION_SIZE = 50
class RustTimeline( class RustTimeline(
private val inner: InnerTimeline, private val inner: InnerTimeline,
mode: Timeline.Mode, override val mode: Timeline.Mode,
systemClock: SystemClock, systemClock: SystemClock,
private val joinedRoom: JoinedRoom, private val joinedRoom: JoinedRoom,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
@ -118,19 +118,20 @@ class RustTimeline(
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode) private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
override val backwardPaginationStatus = MutableStateFlow( override val backwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS) Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PinnedEvents)
) )
override val forwardPaginationStatus = MutableStateFlow( override val forwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT) Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode !is Timeline.Mode.FocusedOnEvent)
) )
init { init {
if (mode != Timeline.Mode.PINNED_EVENTS) { when (mode) {
coroutineScope.fetchMembers() is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
else -> Unit
} }
if (mode == Timeline.Mode.LIVE) { if (mode == Timeline.Mode.Live) {
// When timeline is live, we need to listen to the back pagination status as // When timeline is live, we need to listen to the back pagination status as
// sdk can automatically paginate backwards. // sdk can automatically paginate backwards.
coroutineScope.registerBackPaginationStatusListener() coroutineScope.registerBackPaginationStatusListener()
@ -219,6 +220,7 @@ class RustTimeline(
items = items, items = items,
hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad, hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad,
hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad, hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad,
timelineMode = mode,
) )
} }
.let { items -> .let { items ->

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.timeline.item.event package io.element.android.libraries.matrix.impl.timeline.item.event
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -37,14 +38,14 @@ private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
class EventMessageMapper { class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) } private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, isThreaded: Boolean): MessageContent = message.use { fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo): MessageContent = message.use {
val type = it.content.msgType.use(this::mapMessageType) val type = it.content.msgType.use(this::mapMessageType)
val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map) val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map)
MessageContent( MessageContent(
body = it.content.body, body = it.content.body,
inReplyTo = inReplyToEvent, inReplyTo = inReplyToEvent,
isEdited = it.content.isEdited, isEdited = it.content.isEdited,
isThreaded = isThreaded, threadInfo = threadInfo,
type = type type = type
) )
} }

View file

@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.timeline.item.event
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId
fun RustEventOrTransactionId.map(): EventOrTransactionId = when (this) {
is RustEventOrTransactionId.EventId -> EventOrTransactionId.Event(EventId(eventId))
is RustEventOrTransactionId.TransactionId -> EventOrTransactionId.Transaction(TransactionId(transactionId))
}

View file

@ -7,7 +7,12 @@
package io.element.android.libraries.matrix.impl.timeline.item.event package io.element.android.libraries.matrix.impl.timeline.item.event
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
@ -27,6 +32,7 @@ import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map import io.element.android.libraries.matrix.impl.poll.map
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.EmbeddedEventDetails
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
@ -59,8 +65,35 @@ class TimelineEventContentMapper(
when (val kind = it.content.kind) { when (val kind = it.content.kind) {
is MsgLikeKind.Message -> { is MsgLikeKind.Message -> {
val inReplyTo = it.content.inReplyTo val inReplyTo = it.content.inReplyTo
val isThreaded = it.content.threadRoot != null val threadSummary = it.content.threadSummary?.use { summary ->
eventMessageMapper.map(kind, inReplyTo, isThreaded) val numberOfReplies = summary.numReplies().toLong()
val latestEvent = summary.latestEvent()
val details = when (latestEvent) {
is EmbeddedEventDetails.Unavailable -> AsyncData.Uninitialized
is EmbeddedEventDetails.Pending -> AsyncData.Loading()
is EmbeddedEventDetails.Error -> AsyncData.Failure(IllegalStateException(latestEvent.message))
is EmbeddedEventDetails.Ready -> {
AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = latestEvent.eventOrTransactionId.map(),
content = map(latestEvent.content),
senderId = UserId(latestEvent.sender),
senderProfile = latestEvent.senderProfile.map(),
timestamp = latestEvent.timestamp.toLong()
)
)
}
}
ThreadSummary(
latestEvent = details,
numberOfReplies = numberOfReplies,
)
}
val threadInfo = EventThreadInfo(
threadRootId = it.content.threadRoot?.let(::ThreadId),
threadSummary = threadSummary,
)
eventMessageMapper.map(kind, inReplyTo, threadInfo)
} }
is MsgLikeKind.Redacted -> { is MsgLikeKind.Redacted -> {
RedactedContent RedactedContent

View file

@ -24,7 +24,7 @@ class LastForwardIndicatorsPostProcessor(
items: List<MatrixTimelineItem>, items: List<MatrixTimelineItem>,
): List<MatrixTimelineItem> { ): List<MatrixTimelineItem> {
// We don't need to add the last forward indicator if we are not in the FOCUSED_ON_EVENT mode // We don't need to add the last forward indicator if we are not in the FOCUSED_ON_EVENT mode
if (mode != Timeline.Mode.FOCUSED_ON_EVENT) { if (mode !is Timeline.Mode.FocusedOnEvent) {
return items return items
} else { } else {
return buildList { return buildList {

View file

@ -18,8 +18,9 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
items: List<MatrixTimelineItem>, items: List<MatrixTimelineItem>,
hasMoreToLoadBackward: Boolean, hasMoreToLoadBackward: Boolean,
hasMoreToLoadForward: Boolean, hasMoreToLoadForward: Boolean,
timelineMode: Timeline.Mode,
): List<MatrixTimelineItem> { ): List<MatrixTimelineItem> {
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty() val shouldAddForwardLoadingIndicator = timelineMode is Timeline.Mode.Live && hasMoreToLoadForward && items.isNotEmpty()
val currentTimestamp = systemClock.epochMillis() val currentTimestamp = systemClock.epochMillis()
return buildList { return buildList {
if (hasMoreToLoadBackward) { if (hasMoreToLoadBackward) {

View file

@ -28,7 +28,7 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
): List<MatrixTimelineItem> { ): List<MatrixTimelineItem> {
return when { return when {
items.isEmpty() -> items items.isEmpty() -> items
mode == Timeline.Mode.PINNED_EVENTS -> items mode == Timeline.Mode.PinnedEvents -> items
isDm -> processForDM(items, roomCreator) isDm -> processForDM(items, roomCreator)
hasMoreToLoadBackwards -> items hasMoreToLoadBackwards -> items
else -> processForRoom(items) else -> processForRoom(items)

View file

@ -17,7 +17,7 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
*/ */
class TypingNotificationPostProcessor(private val mode: Timeline.Mode) { class TypingNotificationPostProcessor(private val mode: Timeline.Mode) {
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> { fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
return if (mode == Timeline.Mode.LIVE) { return if (mode is Timeline.Mode.Live) {
buildList { buildList {
addAll(items) addAll(items)
add( add(

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory
@ -66,5 +67,6 @@ class RustMatrixClientTest {
baseCacheDirectory = File(""), baseCacheDirectory = File(""),
clock = FakeSystemClock(), clock = FakeSystemClock(),
timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(),
featureFlagService = FakeFeatureFlagService(),
) )
} }

View file

@ -39,6 +39,7 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) {
override fun userAgent(userAgent: String) = this override fun userAgent(userAgent: String) = this
override fun username(username: String) = this override fun username(username: String) = this
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
override suspend fun build(): Client { override suspend fun build(): Client {
return FakeFfiClient(withUtdHook = {}) return FakeFfiClient(withUtdHook = {})

View file

@ -90,7 +90,7 @@ class RustTimelineTest {
private fun TestScope.createRustTimeline( private fun TestScope.createRustTimeline(
inner: InnerTimeline, inner: InnerTimeline,
mode: Timeline.Mode = Timeline.Mode.LIVE, mode: Timeline.Mode = Timeline.Mode.Live,
systemClock: SystemClock = FakeSystemClock(), systemClock: SystemClock = FakeSystemClock(),
joinedRoom: JoinedRoom = FakeJoinedRoom().apply { givenRoomInfo(aRoomInfo()) }, joinedRoom: JoinedRoom = FakeJoinedRoom().apply { givenRoomInfo(aRoomInfo()) },
coroutineScope: CoroutineScope = backgroundScope, coroutineScope: CoroutineScope = backgroundScope,

View file

@ -12,19 +12,20 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import org.junit.Test import org.junit.Test
class LastForwardIndicatorsPostProcessorTest { class LastForwardIndicatorsPostProcessorTest {
@Test @Test
fun `LastForwardIndicatorsPostProcessor does not alter the items with mode not FOCUSED_ON_EVENT`() { fun `LastForwardIndicatorsPostProcessor does not alter the items with mode not FOCUSED_ON_EVENT`() {
val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.LIVE) val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.Live)
val result = sut.process(listOf(messageEvent)) val result = sut.process(listOf(messageEvent))
assertThat(result).containsExactly(messageEvent) assertThat(result).containsExactly(messageEvent)
} }
@Test @Test
fun `LastForwardIndicatorsPostProcessor add virtual items`() { fun `LastForwardIndicatorsPostProcessor add virtual items`() {
val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FOCUSED_ON_EVENT) val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID))
val result = sut.process(listOf(messageEvent)) val result = sut.process(listOf(messageEvent))
assertThat(result).containsExactly( assertThat(result).containsExactly(
messageEvent, messageEvent,
@ -37,7 +38,7 @@ class LastForwardIndicatorsPostProcessorTest {
@Test @Test
fun `LastForwardIndicatorsPostProcessor add virtual items on empty list`() { fun `LastForwardIndicatorsPostProcessor add virtual items on empty list`() {
val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FOCUSED_ON_EVENT) val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID))
val result = sut.process(listOf()) val result = sut.process(listOf())
assertThat(result).containsExactly( assertThat(result).containsExactly(
MatrixTimelineItem.Virtual( MatrixTimelineItem.Virtual(
@ -49,7 +50,7 @@ class LastForwardIndicatorsPostProcessorTest {
@Test @Test
fun `LastForwardIndicatorsPostProcessor add virtual items but does not alter the list if called a second time`() { fun `LastForwardIndicatorsPostProcessor add virtual items but does not alter the list if called a second time`() {
val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FOCUSED_ON_EVENT) val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID))
// Process a first time // Process a first time
sut.process(listOf(messageEvent)) sut.process(listOf(messageEvent))
// Process a second time with the same Event // Process a second time with the same Event
@ -65,7 +66,7 @@ class LastForwardIndicatorsPostProcessorTest {
@Test @Test
fun `LastForwardIndicatorsPostProcessor add virtual items each time it is called with new Events`() { fun `LastForwardIndicatorsPostProcessor add virtual items each time it is called with new Events`() {
val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FOCUSED_ON_EVENT) val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID))
// Process a first time // Process a first time
sut.process(listOf(dayEvent, messageEvent)) sut.process(listOf(dayEvent, messageEvent))
// Process a second time with the same Event // Process a second time with the same Event

Some files were not shown because too many files have changed in this diff Show more