diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index d299887165..4ce33a748a 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(libs.coil.compose) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt index c611340aea..1528e6cb14 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt @@ -7,11 +7,19 @@ 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. * * 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 + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt index e4c7b14768..cf601a412e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt @@ -13,12 +13,21 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.timeline.Timeline import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultSendLocationEntryPoint @Inject constructor() : SendLocationEntryPoint { - override fun createNode( - parentNode: Node, - buildContext: BuildContext - ): SendLocationNode = parentNode.createNode(buildContext) + override fun builder(timelineMode: Timeline.Mode): SendLocationEntryPoint.Builder { + return Builder(timelineMode) + } + + class Builder(private val timelineMode: Timeline.Mode) : SendLocationEntryPoint.Builder { + override fun build(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(SendLocationNode.Inputs(timelineMode)) + ) + } + } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt index 4fd438c96e..97e78fcb07 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt @@ -17,16 +17,25 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen 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.matrix.api.timeline.Timeline import io.element.android.services.analytics.api.AnalyticsService @ContributesNode(RoomScope::class) class SendLocationNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: SendLocationPresenter, + presenterFactory: SendLocationPresenter.Factory, analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { + data class Inputs( + val timelineMode: Timeline.Mode, + ) : NodeInputs + + private val presenter = presenterFactory.create(inputs().timelineMode) + init { lifecycle.subscribe( onResume = { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt index e4859da08d..2619352af1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt @@ -15,6 +15,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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 io.element.android.features.location.impl.common.MapDefaults 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.messages.api.MessageComposerContext 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.matrix.api.room.CreateTimelineParams 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.timeline.Timeline import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.launch -import javax.inject.Inject -class SendLocationPresenter @Inject constructor( +class SendLocationPresenter @AssistedInject constructor( permissionsPresenterFactory: PermissionsPresenter.Factory, private val room: JoinedRoom, + @Assisted private val timelineMode: Timeline.Mode, private val analyticsService: AnalyticsService, private val messageComposerContext: MessageComposerContext, private val locationActions: LocationActions, private val buildMeta: BuildMeta, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(timelineMode: Timeline.Mode): SendLocationPresenter + } + private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) @Composable @@ -104,14 +115,16 @@ class SendLocationPresenter @Inject constructor( when (mode) { SendLocationState.Mode.PinLocation -> { val geoUri = event.cameraPosition.toGeoUri() - room.liveTimeline.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - assetType = AssetType.PIN, - inReplyToEventId = inReplyToEventId, - ) + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = AssetType.PIN, + inReplyToEventId = inReplyToEventId, + ) + } analyticsService.capture( Composer( inThread = messageComposerContext.composerMode.inThread, @@ -123,14 +136,16 @@ class SendLocationPresenter @Inject constructor( } SendLocationState.Mode.SenderLocation -> { val geoUri = event.toGeoUri() - room.liveTimeline.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - assetType = AssetType.SENDER, - inReplyToEventId = inReplyToEventId, - ) + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = AssetType.SENDER, + inReplyToEventId = inReplyToEventId, + ) + } analyticsService.capture( Composer( inThread = messageComposerContext.composerMode.inThread, @@ -142,6 +157,13 @@ class SendLocationPresenter @Inject constructor( } } } + + private suspend fun getTimeline(): Result { + 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() diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt index 543d71eae1..4847942d67 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt @@ -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.room.JoinedRoom 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.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.core.aBuildMeta @@ -55,6 +56,7 @@ class SendLocationPresenterTest { override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, room = joinedRoom, + timelineMode = Timeline.Mode.Live, analyticsService = fakeAnalyticsService, messageComposerContext = fakeMessageComposerContext, locationActions = fakeLocationActions, diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index 7eefee11cc..a6a2edcf6d 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.preferences.api) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerEvents.kt similarity index 93% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt rename to features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerEvents.kt index 30053ba7b9..9586fd0bf7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerEvents.kt @@ -5,7 +5,7 @@ * 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 io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt new file mode 100644 index 0000000000..0e464e93b8 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -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 { + interface Factory { + fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerState.kt similarity index 87% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt rename to features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerState.kt index 2e8c4caa87..e78a2b5611 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerState.kt @@ -5,7 +5,7 @@ * 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 io.element.android.libraries.textcomposer.model.VoiceMessageState diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt similarity index 80% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt rename to features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt index 534c45dade..e1bfee9a7f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt @@ -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 * 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 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 kotlin.time.Duration.Companion.seconds -internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider { +open class VoiceMessageComposerStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = aWaveformLevels)), ) } -internal fun aVoiceMessageComposerState( +fun aVoiceMessageComposerState( voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, keepScreenOn: Boolean = false, showPermissionRationaleDialog: Boolean = false, @@ -33,7 +33,7 @@ internal fun aVoiceMessageComposerState( eventSink = {}, ) -internal fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( +fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( isSending = false, isPlaying = false, showCursor = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index a0df0615eb..266d2459f0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -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.list.PinnedMessagesListNode 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.debug.EventDebugInfoNode 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.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.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.media.MediaSource @@ -139,7 +141,7 @@ class MessagesFlowNode @AssistedInject constructor( ) : NavTarget @Parcelize - data class AttachmentPreview(val attachment: Attachment) : NavTarget + data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment) : NavTarget @Parcelize 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 @Parcelize - data object SendLocation : NavTarget + data class SendLocation(val timelineMode: Timeline.Mode) : NavTarget @Parcelize - data object CreatePoll : NavTarget + data class CreatePoll(val timelineMode: Timeline.Mode) : NavTarget @Parcelize - data class EditPoll(val eventId: EventId) : NavTarget + data class EditPoll(val timelineMode: Timeline.Mode, val eventId: EventId) : NavTarget @Parcelize data object PinnedMessagesList : NavTarget @Parcelize data object KnockRequestsList : NavTarget + + @Parcelize + data class OpenThread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget } private val callbacks = plugins() @@ -211,15 +216,18 @@ class MessagesFlowNode @AssistedInject constructor( 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( - timelineMode = if (isLive) Timeline.Mode.LIVE else Timeline.Mode.FOCUSED_ON_EVENT, + timelineMode = timelineMode, event = event, ) } override fun onPreviewAttachments(attachments: ImmutableList) { - backstack.push(NavTarget.AttachmentPreview(attachments.first())) + backstack.push(NavTarget.AttachmentPreview( + attachment = attachments.first(), + timelineMode = Timeline.Mode.Live, + )) } override fun onUserDataClick(userId: UserId) { @@ -243,15 +251,15 @@ class MessagesFlowNode @AssistedInject constructor( } override fun onSendLocationClick() { - backstack.push(NavTarget.SendLocation) + backstack.push(NavTarget.SendLocation(Timeline.Mode.Live)) } override fun onCreatePollClick() { - backstack.push(NavTarget.CreatePoll) + backstack.push(NavTarget.CreatePoll(Timeline.Mode.Live)) } override fun onEditPollClick(eventId: EventId) { - backstack.push(NavTarget.EditPoll(eventId)) + backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId)) } override fun onJoinCallClick(roomId: RoomId) { @@ -270,6 +278,10 @@ class MessagesFlowNode @AssistedInject constructor( override fun onViewKnockRequests() { backstack.push(NavTarget.KnockRequestsList) } + + override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { + backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId)) + } } val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) createNode(buildContext, listOf(callback, inputs)) @@ -298,7 +310,10 @@ class MessagesFlowNode @AssistedInject constructor( .build() } is NavTarget.AttachmentPreview -> { - val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) + val inputs = AttachmentsPreviewNode.Inputs( + attachment = navTarget.attachment, + timelineMode = navTarget.timelineMode, + ) createNode(buildContext, listOf(inputs)) } is NavTarget.LocationViewer -> { @@ -327,24 +342,34 @@ class MessagesFlowNode @AssistedInject constructor( val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) createNode(buildContext, listOf(inputs)) } - NavTarget.SendLocation -> { - sendLocationEntryPoint.createNode(this, buildContext) + is NavTarget.SendLocation -> { + sendLocationEntryPoint + .builder(navTarget.timelineMode) + .build(this, buildContext) } - NavTarget.CreatePoll -> { + is NavTarget.CreatePoll -> { createPollEntryPoint.nodeBuilder(this, buildContext) - .params(CreatePollEntryPoint.Params(mode = CreatePollMode.NewPoll)) + .params(CreatePollEntryPoint.Params( + timelineMode = navTarget.timelineMode, + mode = CreatePollMode.NewPoll + )) .build() } is NavTarget.EditPoll -> { 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() } NavTarget.PinnedMessagesList -> { val callback = object : PinnedMessagesListNode.Callback { override fun onEventClick(event: TimelineItem.Event) { processEventClick( - timelineMode = Timeline.Mode.PINNED_EVENTS, + timelineMode = Timeline.Mode.PinnedEvents, event = event, ) } @@ -377,6 +402,69 @@ class MessagesFlowNode @AssistedInject constructor( NavTarget.KnockRequestsList -> { 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) { + 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(buildContext, listOf(inputs, callback)) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index d3c50bc133..ad8e6c6081 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl 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.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.timeline.item.TimelineItemDebugInfo import kotlinx.collections.immutable.ImmutableList @@ -21,4 +22,5 @@ interface MessagesNavigator { fun onEditPollClick(eventId: EventId) fun onPreviewAttachment(attachments: ImmutableList) fun onNavigateToRoom(roomId: RoomId, serverNames: List) + fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 41b88be821..d286f8a4c7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -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.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 @@ -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.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.core.toRoomIdOrAlias 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.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.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 @@ -75,9 +78,8 @@ class MessagesNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, @ApplicationContext private val context: Context, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, - private val room: BaseRoom, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val room: JoinedRoom, private val analyticsService: AnalyticsService, messageComposerPresenterFactory: MessageComposerPresenter.Factory, timelinePresenterFactory: TimelinePresenter.Factory, @@ -89,11 +91,16 @@ class MessagesNode @AssistedInject constructor( private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, ) : Node(buildContext, plugins = plugins), MessagesNavigator { + private val timelineController = TimelineController(room, room.liveTimeline) private val presenter = presenterFactory.create( navigator = this, - composerPresenter = messageComposerPresenterFactory.create(this), - timelinePresenter = timelinePresenterFactory.create(this), - actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default) + composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), + actionListPresenter = actionListPresenterFactory.create( + postProcessor = TimelineItemActionPostProcessor.Default, + timelineMode = timelineController.mainTimelineMode() + ), + timelineController = timelineController, ) private val callbacks = plugins() @@ -103,7 +110,7 @@ class MessagesNode @AssistedInject constructor( interface Callback : Plugin { fun onRoomDetailsClick() - fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean + fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean fun onPreviewAttachments(attachments: ImmutableList) fun onUserDataClick(userId: UserId) fun onPermalinkClick(data: PermalinkData) @@ -116,6 +123,7 @@ class MessagesNode @AssistedInject constructor( fun onJoinCallClick(roomId: RoomId) fun onViewAllPinnedEvents() fun onViewKnockRequests() + fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } override fun onBuilt() { @@ -134,12 +142,12 @@ class MessagesNode @AssistedInject constructor( 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: // - 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(isLive, event) } + ?.map { it.onEventClick(timelineMode, event) } ?.all { it } .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() { callbacks.forEach { it.onViewAllPinnedEvents() } } @@ -265,7 +277,18 @@ class MessagesNode @AssistedInject constructor( state = state, onBackClick = this::navigateUp, 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, onLinkClick = { url, customTab -> onLinkClick(activity, isDark, url, state.timelineState.eventSink, customTab) }, onSendLocationClick = this::onSendLocationClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 434f6643d4..380a868aec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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.TimelineItemTextBasedContent 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.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.RoomMemberModerationState @@ -93,7 +93,7 @@ class MessagesPresenter @AssistedInject constructor( @Assisted private val navigator: MessagesNavigator, private val room: JoinedRoom, @Assisted private val composerPresenter: Presenter, - private val voiceMessageComposerPresenter: Presenter, + voiceMessageComposerPresenterFactory: DefaultVoiceMessageComposerPresenter.Factory, @Assisted private val timelinePresenter: Presenter, private val timelineProtectionPresenter: Presenter, private val identityChangeStatePresenter: Presenter, @@ -111,7 +111,7 @@ class MessagesPresenter @AssistedInject constructor( private val clipboardHelper: ClipboardHelper, private val htmlConverterProvider: HtmlConverterProvider, private val buildMeta: BuildMeta, - private val timelineController: TimelineController, + @Assisted private val timelineController: TimelineController, private val permalinkParser: PermalinkParser, private val analyticsService: AnalyticsService, private val encryptionService: EncryptionService, @@ -123,9 +123,14 @@ class MessagesPresenter @AssistedInject constructor( composerPresenter: Presenter, timelinePresenter: Presenter, actionListPresenter: Presenter, + timelineController: TimelineController, ): MessagesPresenter } + private val voiceMessageComposerPresenter = voiceMessageComposerPresenterFactory.create( + timelineMode = timelineController.mainTimelineMode() + ) + @Composable override fun present(): MessagesState { htmlConverterProvider.Update() @@ -145,9 +150,8 @@ class MessagesPresenter @AssistedInject constructor( val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val roomCallState = roomCallStatePresenter.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 { derivedStateOf { roomInfo.avatarData() } @@ -264,8 +268,13 @@ class MessagesPresenter @AssistedInject constructor( } @Composable - private fun userEventPermissions(updateKey: Long): State { - return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { + private fun userEventPermissions(roomInfo: RoomInfo): State { + 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( canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).getOrElse { true }, canSendReaction = room.canSendMessage(type = MessageEventType.REACTION).getOrElse { true }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index ddeff51b7e..3d05da5a3c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl 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.crypto.identity.IdentityChangeState 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.receipt.bottomsheet.ReadReceiptBottomSheetState 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.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.architecture.AsyncData diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index b45bb781c5..8217cb977c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -8,6 +8,9 @@ package io.element.android.features.messages.impl 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.anActionListState 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.protection.TimelineProtectionState 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.aStandByCallState 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.AvatarSize 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.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.aTextEditorStateRich import kotlinx.collections.immutable.persistentListOf @@ -84,6 +86,10 @@ open class MessagesStateProvider : PreviewParameterProvider { 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(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)), + aMessagesState(timelineState = aTimelineState( + timelineMode = Timeline.Mode.Thread(threadRootId = ThreadId("\$a-thread-id")), + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + )), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 74f2fb18a5..ccbebbbc6d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents 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.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.ReadReceiptBottomSheetEvents 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.VoiceMessageSendingFailedDialog 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.encryption.identity.IdentityState 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.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings @@ -196,17 +197,21 @@ fun MessagesView( topBar = { Column { ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) - MessagesViewTopBar( - roomName = state.roomName, - roomAvatar = state.roomAvatar, - isTombstoned = state.isTombstoned, - heroes = state.heroes, - roomCallState = state.roomCallState, - dmUserIdentityState = state.dmUserVerificationState, - onBackClick = { hidingKeyboard { onBackClick() } }, - onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - onJoinCallClick = onJoinCallClick, - ) + if (state.timelineState.timelineMode is Timeline.Mode.Thread) { + ThreadTopBar(onBackClick = onBackClick) + } else { + MessagesViewTopBar( + roomName = state.roomName, + roomAvatar = state.roomAvatar, + isTombstoned = state.isTombstoned, + heroes = state.heroes, + roomCallState = state.roomCallState, + dmUserIdentityState = state.dmUserVerificationState, + onBackClick = { hidingKeyboard { onBackClick() } }, + onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, + onJoinCallClick = onJoinCallClick, + ) + } } }, content = { padding -> @@ -414,23 +419,26 @@ private fun MessagesViewContent( onJoinCallClick = onJoinCallClick, nestedScrollConnection = scrollBehavior.nestedScrollConnection, ) - AnimatedVisibility( - visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - fun focusOnPinnedEvent(eventId: EventId) { - state.timelineState.eventSink( - TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) + + if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { + AnimatedVisibility( + visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + 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( - state = state.pinnedMessagesBannerState, - onClick = ::focusOnPinnedEvent, - onViewAllClick = onViewAllPinnedMessagesClick, - ) + knockRequestsBannerView() } - 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 private fun RoomAvatarAndNameRow( roomName: String?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 375e2bdefe..fb2cd915bc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.RoomScope 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.timeline.Timeline import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -51,13 +52,18 @@ import kotlinx.coroutines.launch interface ActionListPresenter : Presenter { interface Factory { - fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter + fun create( + postProcessor: TimelineItemActionPostProcessor, + timelineMode: Timeline.Mode, + ): ActionListPresenter } } class DefaultActionListPresenter @AssistedInject constructor( @Assisted private val postProcessor: TimelineItemActionPostProcessor, + @Assisted + private val timelineMode: Timeline.Mode, private val appPreferencesStore: AppPreferencesStore, private val room: BaseRoom, private val userSendFailureFactory: VerifiedUserSendFailureFactory, @@ -66,7 +72,10 @@ class DefaultActionListPresenter @AssistedInject constructor( @AssistedFactory @ContributesBinding(RoomScope::class) interface Factory : ActionListPresenter.Factory { - override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter + override fun create( + postProcessor: TimelineItemActionPostProcessor, + timelineMode: Timeline.Mode, + ): DefaultActionListPresenter } private val comparator = TimelineItemActionComparator() @@ -150,7 +159,7 @@ class DefaultActionListPresenter @AssistedInject constructor( val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther return buildSet { if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { - if (timelineItem.isThreaded) { + if (timelineMode !is Timeline.Mode.Thread && timelineItem.threadInfo.threadRootId != null) { add(TimelineItemAction.ReplyInThread) } else { add(TimelineItemAction.Reply) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt index 720cfacfb5..735d5548e8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt @@ -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.inputs 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 @ContributesNode(RoomScope::class) @@ -29,7 +30,10 @@ class AttachmentsPreviewNode @AssistedInject constructor( presenterFactory: AttachmentsPreviewPresenter.Factory, private val localMediaRenderer: LocalMediaRenderer, ) : 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() @@ -39,6 +43,7 @@ class AttachmentsPreviewNode @AssistedInject constructor( private val presenter = presenterFactory.create( attachment = inputs.attachment, + timelineMode = inputs.timelineMode, onDoneListener = onDoneListener, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 93502d3175..10f99b7641 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -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.matrix.api.core.EventId 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.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo @@ -50,7 +51,8 @@ import timber.log.Timber class AttachmentsPreviewPresenter @AssistedInject constructor( @Assisted private val attachment: Attachment, @Assisted private val onDoneListener: OnDoneListener, - private val mediaSender: MediaSender, + @Assisted private val timelineMode: Timeline.Mode, + mediaSenderFactory: MediaSender.Factory, private val permalinkBuilder: PermalinkBuilder, private val temporaryUriDeleter: TemporaryUriDeleter, private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, @@ -61,10 +63,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( interface Factory { fun create( attachment: Attachment, + timelineMode: Timeline.Mode, onDoneListener: OnDoneListener, ): AttachmentsPreviewPresenter } + private val mediaSender = mediaSenderFactory.create(timelineMode) + @Composable override fun present(): AttachmentsPreviewState { val coroutineScope = rememberCoroutineScope() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt similarity index 90% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt index ed84ef9df0..5148ea0216 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt @@ -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.typing.TypingNotificationPresenter 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.di.RoomScope @ContributesTo(RoomScope::class) @Module -interface MessagesModule { +interface MessagesBindsModule { @Binds fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter @@ -51,9 +49,6 @@ interface MessagesModule { @Binds fun bindLinkPresenter(presenter: LinkPresenter): Presenter - @Binds - fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter - @Binds fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt new file mode 100644 index 0000000000..970aa63b75 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index d6cf3dc811..e68dc3410f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -97,13 +97,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes class MessageComposerPresenter @AssistedInject constructor( @Assisted private val navigator: MessagesNavigator, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, + @Assisted private val timelineController: TimelineController, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val room: JoinedRoom, private val mediaPickerProvider: PickerProvider, private val sessionPreferencesStore: SessionPreferencesStore, private val localMediaFactory: LocalMediaFactory, - private val mediaSender: MediaSender, + private val mediaSenderFactory: MediaSender.Factory, private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, private val locationService: LocationService, @@ -113,7 +113,6 @@ class MessageComposerPresenter @AssistedInject constructor( private val permalinkParser: PermalinkParser, private val permalinkBuilder: PermalinkBuilder, permissionsPresenterFactory: PermissionsPresenter.Factory, - private val timelineController: TimelineController, private val draftService: ComposerDraftService, private val mentionSpanProvider: MentionSpanProvider, private val pillificationHelper: TextPillificationHelper, @@ -122,9 +121,11 @@ class MessageComposerPresenter @AssistedInject constructor( ) : Presenter { @AssistedFactory 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 var pendingEvent: MessageComposerEvents? = null private val suggestionSearchTrigger = MutableStateFlow(null) @@ -423,11 +424,13 @@ class MessageComposerPresenter @AssistedInject constructor( resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) when (capturedMode) { is MessageComposerMode.Attachment, - is MessageComposerMode.Normal -> room.liveTimeline.sendMessage( - body = message.markdown, - htmlBody = message.html, - intentionalMentions = message.intentionalMentions - ) + is MessageComposerMode.Normal -> timelineController.invokeOnCurrentTimeline { + sendMessage( + body = message.markdown, + htmlBody = message.html, + intentionalMentions = message.intentionalMentions + ) + } is MessageComposerMode.Edit -> { timelineController.invokeOnCurrentTimeline { // First try to edit the message in the current timeline diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 0cc5bd93ec..7206d2571b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -17,10 +17,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents -import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState -import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider -import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState +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.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider +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.PreviewsDayNight import io.element.android.libraries.textcomposer.TextComposer diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 2dbf76bc8d..45bed2cc20 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -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.permalink.PermalinkData 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.user.MatrixUser import io.element.android.libraries.ui.strings.CommonStrings @@ -56,7 +57,10 @@ class PinnedMessagesListNode @AssistedInject constructor( private val presenter = presenterFactory.create( navigator = this, - actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor()) + actionListPresenter = actionListPresenterFactory.create( + postProcessor = PinnedMessagesListTimelineActionPostProcessor(), + timelineMode = Timeline.Mode.PinnedEvents, + ) ) private val callbacks = plugins() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 1c9e18c9e6..68d2e26109 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -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.SnackbarMessage import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags 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.canRedactOther @@ -71,6 +73,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val analyticsService: AnalyticsService, + private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory interface Factory { @@ -115,6 +118,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor( val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.HideThreadedEvents).collectAsState(false) + var pinnedMessageItems by remember { mutableStateOf>>(AsyncData.Uninitialized) } @@ -134,6 +139,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, linkState = linkState, + displayThreadSummaries = displayThreadSummaries, userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, eventSink = ::handleEvents @@ -230,6 +236,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( private fun pinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo, timelineProtectionState: TimelineProtectionState, + displayThreadSummaries: Boolean, linkState: LinkState, userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, @@ -246,6 +253,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, + displayThreadSummaries = displayThreadSummaries, linkState = linkState, userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index d702e2d40f..8d71899134 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -33,6 +33,7 @@ sealed interface PinnedMessagesListState { val timelineItems: ImmutableList, val actionListState: ActionListState, val linkState: LinkState, + val displayThreadSummaries: Boolean, val eventSink: (PinnedMessagesListEvents) -> Unit, ) : PinnedMessagesListState { val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt index 2a9b7a085c..70b457b90c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -92,6 +92,7 @@ fun aLoadedPinnedMessagesListState( timelineItems: List = emptyList(), actionListState: ActionListState = anActionListState(), aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, + displayThreadSummaries: Boolean = false, eventSink: (PinnedMessagesListEvents) -> Unit = {} ) = PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, @@ -100,5 +101,6 @@ fun aLoadedPinnedMessagesListState( timelineItems = timelineItems.toImmutableList(), actionListState = actionListState, userEventPermissions = aUserEventPermissions, + displayThreadSummaries = displayThreadSummaries, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 86cd8c740f..0abfc4d75a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -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.Scaffold 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.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService @@ -126,6 +127,7 @@ private fun PinnedMessagesListContent( PinnedMessagesListState.Empty -> PinnedMessagesListEmpty() is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded( state = state, + displayThreadSummaries = state.displayThreadSummaries, onEventClick = onEventClick, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, @@ -163,6 +165,7 @@ private fun PinnedMessagesListEmpty( @Composable private fun PinnedMessagesListLoaded( state: PinnedMessagesListState.Filled, + displayThreadSummaries: Boolean, onEventClick: (event: TimelineItem.Event) -> Unit, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, @@ -210,6 +213,7 @@ private fun PinnedMessagesListLoaded( ) { timelineItem -> TimelineItemRow( timelineItem = timelineItem, + timelineMode = Timeline.Mode.PinnedEvents, timelineRoomInfo = state.timelineRoomInfo, renderReadReceipts = false, timelineProtectionState = state.timelineProtectionState, @@ -222,6 +226,7 @@ private fun PinnedMessagesListLoaded( onLinkLongClick = onLinkLongClick, onContentClick = onEventClick, onLongClick = ::onMessageLongClick, + displayThreadSummaries = displayThreadSummaries, inReplyToClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt new file mode 100644 index 0000000000..cde141dcd6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -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, + @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() + + data class Inputs( + val threadRootEventId: ThreadId, + val focusedEventId: EventId?, + ) : NodeInputs + + private val 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) + 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) { + callbacks.forEach { it.onPreviewAttachments(attachments) } + } + + override fun onNavigateToRoom(roomId: RoomId, serverNames: List) = 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 + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index 4800d64c9b..6289feecfd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl.timeline 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.SingleIn import io.element.android.libraries.matrix.api.core.EventId @@ -43,11 +44,12 @@ import javax.inject.Inject @ContributesBinding(RoomScope::class, boundType = TimelineProvider::class) class TimelineController @Inject constructor( private val room: JoinedRoom, + @LiveTimeline private val liveTimeline: Timeline, ) : Closeable, TimelineProvider { private val coroutineScope = CoroutineScope(SupervisorJob()) - private val liveTimeline = flowOf(room.liveTimeline) - private val detachedTimeline = MutableStateFlow>(Optional.empty()) + private val liveTimelineFlow = flowOf(liveTimeline) + private val detachedTimelineFlow = MutableStateFlow>(Optional.empty()) @OptIn(ExperimentalCoroutinesApi::class) fun timelineItems(): Flow> { @@ -55,7 +57,13 @@ class TimelineController @Inject constructor( } fun isLive(): Flow { - 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)) { @@ -72,7 +80,7 @@ class TimelineController @Inject constructor( } } .map { newDetachedTimeline -> - detachedTimeline.getAndUpdate { current -> + detachedTimelineFlow.getAndUpdate { current -> if (current.isPresent) { current.get().close() } @@ -90,7 +98,7 @@ class TimelineController @Inject constructor( } private fun closeDetachedTimeline() { - detachedTimeline.getAndUpdate { + detachedTimelineFlow.getAndUpdate { when { it.isPresent -> { 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 { detached.isPresent -> detached.get() else -> live diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt index 66d8da771b..0fe4394fa6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -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.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.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import kotlin.time.Duration @@ -31,6 +32,7 @@ sealed interface TimelineEvents { data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem data class ShowShieldDialog(val messageShield: MessageShield) : 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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 86dc1dfb1e..32556eae7d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.coroutine.CoroutineDispatchers 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.UniqueId 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.roomMembers 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.TimelineItemEventOrigin import io.element.android.libraries.matrix.ui.room.canSendMessageAsState @@ -74,16 +78,20 @@ class TimelinePresenter @AssistedInject constructor( private val sendPollResponseAction: SendPollResponseAction, private val endPollAction: EndPollAction, private val sessionPreferencesStore: SessionPreferencesStore, - private val timelineController: TimelineController, + @Assisted private val timelineController: TimelineController, private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), private val resolveVerifiedUserSendFailurePresenter: Presenter, private val typingNotificationPresenter: Presenter, private val roomCallStatePresenter: Presenter, private val markAsFullyRead: MarkAsFullyRead, + private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory interface Factory { - fun create(navigator: MessagesNavigator): TimelinePresenter + fun create( + timelineController: TimelineController, + navigator: MessagesNavigator + ): TimelinePresenter } private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( @@ -97,6 +105,9 @@ class TimelinePresenter @AssistedInject constructor( @Composable override fun present(): TimelineState { val localScope = rememberCoroutineScope() + + val timelineMode = remember { timelineController.mainTimelineMode() } + var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) } val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } @@ -124,9 +135,17 @@ class TimelinePresenter @AssistedInject constructor( timelineController.isLive() }.collectAsState(initial = true) + val displayThreadSummaries by produceState(false) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents) + } + fun handleEvents(event: TimelineEvents) { when (event) { 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 { timelineController.paginate(direction = event.direction) } @@ -148,15 +167,21 @@ class TimelinePresenter @AssistedInject constructor( } } is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch { - sendPollResponseAction.execute( - pollStartId = event.pollStartId, - answerId = event.answerId - ) + timelineController.invokeOnCurrentTimeline { + sendPollResponseAction.execute( + timeline = this, + pollStartId = event.pollStartId, + answerId = event.answerId + ) + } } is TimelineEvents.EndPoll -> sessionCoroutineScope.launch { - endPollAction.execute( - pollStartId = event.pollStartId, - ) + timelineController.invokeOnCurrentTimeline { + endPollAction.execute( + timeline = this, + pollStartId = event.pollStartId, + ) + } } is TimelineEvents.EditPoll -> { navigator.onEditPollClick(event.pollStartId) @@ -183,6 +208,12 @@ class TimelinePresenter @AssistedInject constructor( val serverNames = calculateServerNamesForRoom(room) 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( timelineItems = timelineItems, + timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, newEventState = newEventState.value, @@ -277,6 +309,7 @@ class TimelinePresenter @AssistedInject constructor( focusRequestState = focusRequestState, messageShield = messageShield.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + displayThreadSummaries = displayThreadSummaries, eventSink = { handleEvents(it) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index a3cdb34c94..bd1c58c9d7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -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.UniqueId 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 kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration @@ -24,6 +25,7 @@ import kotlin.time.Duration data class TimelineState( val timelineItems: ImmutableList, val timelineRoomInfo: TimelineRoomInfo, + val timelineMode: Timeline.Mode, val renderReadReceipts: Boolean, val newEventState: NewEventState, val isLive: Boolean, @@ -31,6 +33,7 @@ data class TimelineState( // If not null, info will be rendered in a dialog val messageShield: MessageShield?, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, + val displayThreadSummaries: Boolean, val eventSink: (TimelineEvents) -> Unit, ) { private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 8bb66b4f60..3c03f4a7b0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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.UserId 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.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield @@ -45,12 +47,14 @@ import kotlin.random.Random fun aTimelineState( timelineItems: ImmutableList = persistentListOf(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, renderReadReceipts: Boolean = false, timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), focusedEventIndex: Int = -1, isLive: Boolean = true, messageShield: MessageShield? = null, resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), + displayThreadSummaries: Boolean = false, eventSink: (TimelineEvents) -> Unit = {}, ): TimelineState { val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId @@ -61,6 +65,7 @@ fun aTimelineState( } return TimelineState( timelineItems = timelineItems, + timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, newEventState = NewEventState.None, @@ -68,6 +73,7 @@ fun aTimelineState( focusRequestState = focusRequestState, messageShield = messageShield, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + displayThreadSummaries = displayThreadSummaries, eventSink = eventSink, ) } @@ -140,7 +146,7 @@ internal fun aTimelineItemEvent( groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, sendState: LocalEventSendState? = null, inReplyTo: InReplyToDetails? = null, - isThreaded: Boolean = false, + threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(), @@ -166,7 +172,7 @@ internal fun aTimelineItemEvent( groupPosition = groupPosition, localSendState = sendState, inReplyTo = inReplyTo, - isThreaded = isThreaded, + threadInfo = threadInfo, origin = null, timelineItemDebugInfoProvider = { debugInfo }, messageShieldProvider = { messageShield }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 68bbc9c8b9..0d729020dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -163,11 +163,13 @@ fun TimelineView( ) { timelineItem -> TimelineItemRow( timelineItem = timelineItem, + timelineMode = state.timelineMode, timelineRoomInfo = state.timelineRoomInfo, timelineProtectionState = timelineProtectionState, renderReadReceipts = state.renderReadReceipts, isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, + displayThreadSummaries = state.displayThreadSummaries, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index 8434242abd..0ac882a95f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -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.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.matrix.api.timeline.Timeline // For previews @Composable internal fun ATimelineItemEventRow( event: TimelineItem.Event, + timelineMode: Timeline.Mode = Timeline.Mode.Live, timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), renderReadReceipts: Boolean = false, isLastOutgoingMessage: Boolean = false, timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + displayThreadSummaries: Boolean = false, ) = TimelineItemEventRow( event = event, + timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, timelineProtectionState = timelineProtectionState, isLastOutgoingMessage = isLastOutgoingMessage, + displayThreadSummaries = displayThreadSummaries, onEventClick = {}, onLongClick = {}, onLinkClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 1b798de14f..de4cfe2c2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics 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.TimelineProtectionState 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.components.EqualWidthColumn 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.rememberSwipeableActionsState 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.Text 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.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.getAvatarUrl 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.testtags.TestTags 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.utils.time.isTalkbackActive import io.element.android.wysiwyg.link.Link @@ -116,10 +126,12 @@ private val BUBBLE_INCOMING_OFFSET = 16.dp @Composable fun TimelineItemEventRow( event: TimelineItem.Event, + timelineMode: Timeline.Mode, timelineRoomInfo: TimelineRoomInfo, timelineProtectionState: TimelineProtectionState, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, + displayThreadSummaries: Boolean, onEventClick: () -> Unit, onLongClick: () -> Unit, onLinkClick: (Link) -> Unit, @@ -194,6 +206,7 @@ fun TimelineItemEventRow( } TimelineItemEventRowContent( event = event, + timelineMode = timelineMode, timelineProtectionState = timelineProtectionState, timelineRoomInfo = timelineRoomInfo, interactionSource = interactionSource, @@ -227,6 +240,7 @@ fun TimelineItemEventRow( } else { TimelineItemEventRowContent( event = event, + timelineMode = timelineMode, timelineProtectionState = timelineProtectionState, timelineRoomInfo = timelineRoomInfo, interactionSource = interactionSource, @@ -241,6 +255,25 @@ fun TimelineItemEventRow( 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 TimelineItemReadReceiptView( state = ReadReceiptViewState( @@ -281,6 +314,7 @@ private fun SwipeSensitivity( @Composable private fun TimelineItemEventRowContent( event: TimelineItem.Event, + timelineMode: Timeline.Mode, timelineProtectionState: TimelineProtectionState, timelineRoomInfo: TimelineRoomInfo, interactionSource: MutableInteractionSource, @@ -360,6 +394,7 @@ private fun TimelineItemEventRowContent( ) { MessageEventBubbleContent( event = event, + timelineMode = timelineMode, timelineProtectionState = timelineProtectionState, onMessageLongClick = onLongClick, inReplyToClick = inReplyToClick, @@ -461,6 +496,7 @@ private fun MessageSenderInformation( @Composable private fun MessageEventBubbleContent( event: TimelineItem.Event, + timelineMode: Timeline.Mode, timelineProtectionState: TimelineProtectionState, onMessageLongClick: () -> Unit, inReplyToClick: () -> Unit, @@ -658,7 +694,7 @@ private fun MessageEventBubbleContent( else -> ContentPadding.Textual } CommonLayout( - showThreadDecoration = event.isThreaded, + showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo.threadRootId != null, timestampPosition = timestampPosition, paddingBehaviour = paddingBehaviour, 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, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt index 3de3e2456b..61e86a8d59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt @@ -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.libraries.designsystem.preview.ElementPreview 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.InReplyToDetailsProvider @@ -56,7 +58,10 @@ internal fun TimelineItemEventRowWithReplyContentToPreview( ), inReplyTo = inReplyToDetails, displayNameAmbiguous = displayNameAmbiguous, - isThreaded = true, + threadInfo = EventThreadInfo( + threadRootId = ThreadId("\$thread-root-id"), + threadSummary = null, + ), groupPosition = TimelineItemGroupPosition.Last, ), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index a32f36ced6..0ce8e02ecc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -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.PreviewsDayNight 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.ui.utils.time.isTalkbackActive import io.element.android.wysiwyg.link.Link @@ -38,11 +39,13 @@ import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemGroupedEventsRow( timelineItem: TimelineItem.GroupedEvents, + timelineMode: Timeline.Mode, timelineRoomInfo: TimelineRoomInfo, timelineProtectionState: TimelineProtectionState, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, focusedEventId: EventId?, + displayThreadSummaries: Boolean, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, @@ -81,11 +84,13 @@ fun TimelineItemGroupedEventsRow( isExpanded = isExpanded.value, onExpandGroupClick = ::onExpandGroupClick, timelineItem = timelineItem, + timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, focusedEventId = focusedEventId, renderReadReceipts = renderReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, + displayThreadSummaries = displayThreadSummaries, onClick = onClick, onLongClick = onLongClick, inReplyToClick = inReplyToClick, @@ -107,11 +112,13 @@ private fun TimelineItemGroupedEventsRowContent( isExpanded: Boolean, onExpandGroupClick: () -> Unit, timelineItem: TimelineItem.GroupedEvents, + timelineMode: Timeline.Mode, timelineRoomInfo: TimelineRoomInfo, timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, + displayThreadSummaries: Boolean, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, @@ -161,12 +168,14 @@ private fun TimelineItemGroupedEventsRowContent( } }.forEach { subGroupEvent -> TimelineItemRow( + timelineMode = timelineMode, timelineItem = subGroupEvent, timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, renderReadReceipts = renderReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, @@ -206,11 +215,13 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi isExpanded = true, onExpandGroupClick = {}, timelineItem = events, + timelineMode = Timeline.Mode.Live, timelineRoomInfo = aTimelineRoomInfo(), timelineProtectionState = aTimelineProtectionState(), focusedEventId = events.events.first().eventId, renderReadReceipts = true, isLastOutgoingMessage = false, + displayThreadSummaries = false, onClick = {}, onLongClick = {}, onLinkLongClick = {}, @@ -232,11 +243,13 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi isExpanded = false, onExpandGroupClick = {}, timelineItem = aGroupedEvents(withReadReceipts = true), + timelineMode = Timeline.Mode.Live, timelineRoomInfo = aTimelineRoomInfo(), timelineProtectionState = aTimelineProtectionState(), focusedEventId = null, renderReadReceipts = true, isLastOutgoingMessage = false, + displayThreadSummaries = false, onClick = {}, onLongClick = {}, onLinkLongClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index ff318b3f7e..11d7b91e1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -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.theme.LocalBuildMeta 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.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.time.isTalkbackActive @@ -53,11 +54,13 @@ import kotlin.time.DurationUnit @Composable internal fun TimelineItemRow( timelineItem: TimelineItem, + timelineMode: Timeline.Mode, timelineRoomInfo: TimelineRoomInfo, renderReadReceipts: Boolean, isLastOutgoingMessage: Boolean, timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, + displayThreadSummaries: Boolean, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -161,10 +164,12 @@ internal fun TimelineItemRow( } ), event = timelineItem, + timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, renderReadReceipts = renderReadReceipts, timelineProtectionState = timelineProtectionState, isLastOutgoingMessage = isLastOutgoingMessage, + displayThreadSummaries = displayThreadSummaries, onEventClick = { onContentClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onLinkClick = onLinkClick, @@ -187,11 +192,13 @@ internal fun TimelineItemRow( is TimelineItem.GroupedEvents -> { TimelineItemGroupedEventsRow( timelineItem = timelineItem, + timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, renderReadReceipts = renderReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, onClick = onContentClick, onLongClick = onLongClick, inReplyToClick = inReplyToClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LiveTimeline.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LiveTimeline.kt new file mode 100644 index 0000000000..40624c9911 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LiveTimeline.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index f80c4f85cb..eda3f2a3f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -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.room.RoomMember 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.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.messages.reply.map @@ -67,7 +68,6 @@ class TimelineItemEventFactory @AssistedInject constructor( url = senderProfile.getAvatarUrl(), size = AvatarSize.TimelineSender ) - currentTimelineItem.event return TimelineItem.Event( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, @@ -86,7 +86,7 @@ class TimelineItemEventFactory @AssistedInject constructor( readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), localSendState = currentTimelineItem.event.localSendState, inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), - isThreaded = currentTimelineItem.event.isThreaded(), + threadInfo = currentTimelineItem.event.threadInfo() ?: EventThreadInfo(threadRootId = null, threadSummary = null), origin = currentTimelineItem.event.origin, timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider, messageShieldProvider = currentTimelineItem.event.messageShieldProvider, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index d753acae0d..94e04dd1d8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -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.UniqueId 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.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState @@ -81,7 +82,7 @@ sealed interface TimelineItem { val readReceiptState: TimelineItemReadReceipts, val localSendState: LocalEventSendState?, val inReplyTo: InReplyToDetails?, - val isThreaded: Boolean, + val threadInfo: EventThreadInfo, val origin: TimelineItemEventOrigin?, val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider, val messageShieldProvider: MessageShieldProvider, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt similarity index 90% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 9b723ff72a..b8ecab747a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -19,10 +19,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.core.net.toUri 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 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.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -40,22 +48,29 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber import java.io.File -import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -class VoiceMessageComposerPresenter @Inject constructor( - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, +class DefaultVoiceMessageComposerPresenter @AssistedInject constructor( + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + @Assisted private val timelineMode: Timeline.Mode, private val voiceRecorder: VoiceRecorder, private val analyticsService: AnalyticsService, - private val mediaSender: MediaSender, + mediaSenderFactory: MediaSender.Factory, private val player: VoiceMessageComposerPlayer, private val messageComposerContext: MessageComposerContext, permissionsPresenterFactory: PermissionsPresenter.Factory -) : Presenter { +) : 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 mediaSender = mediaSenderFactory.create(timelineMode) + @Composable override fun present(): VoiceMessageComposerState { val localCoroutineScope = rememberCoroutineScope() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index f6e6fedbc5..c90ee16e47 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl 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.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.timeline.item.TimelineItemDebugInfo 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 onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList) -> Unit = { _ -> lambdaError() }, - private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List) -> Unit = { _, _ -> lambdaError() } + private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List) -> Unit = { _, _ -> lambdaError() }, + private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, ) : MessagesNavigator { override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { onShowEventDebugInfoClickLambda(eventId, debugInfo) @@ -46,4 +48,8 @@ class FakeMessagesNavigator( override fun onNavigateToRoom(roomId: RoomId, serverNames: List) { onNavigateToRoomLambda(roomId, serverNames) } + + override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { + onOpenThreadLambda(threadRootId, focusedEventId) + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index a64fa03e2b..b16398635d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -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.aTimelineItemTextContent 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.voicemessages.composer.FakeDefaultVoiceMessageComposerPresenterFactory import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState 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.RoomMembershipState 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.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId @@ -908,7 +909,10 @@ class MessagesPresenterTest { liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) }, ) - val presenter = createMessagesPresenter(joinedRoom = room, analyticsService = analyticsService) + val presenter = createMessagesPresenter( + joinedRoom = room, + analyticsService = analyticsService, + ) presenter.testWithLifecycleOwner { val messageEvent = aMessageEvent( content = aTimelineItemTextContent() @@ -1047,6 +1051,7 @@ class MessagesPresenterTest { ) val presenter = createMessagesPresenter( joinedRoom = room, + timeline = timeline, ) presenter.testWithLifecycleOwner { skipItems(1) @@ -1168,6 +1173,7 @@ class MessagesPresenterTest { liveTimeline = FakeTimeline(), typingNoticeResult = { Result.success(Unit) }, ), + timeline: Timeline = joinedRoom.liveTimeline, navigator: FakeMessagesNavigator = FakeMessagesNavigator(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), @@ -1188,7 +1194,7 @@ class MessagesPresenterTest { return MessagesPresenter( room = joinedRoom, composerPresenter = messageComposerPresenter, - voiceMessageComposerPresenter = { aVoiceMessageComposerState() }, + voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, timelineProtectionPresenter = { aTimelineProtectionState() }, actionListPresenter = { anActionListState(eventSink = actionListEventSink) }, @@ -1207,7 +1213,7 @@ class MessagesPresenterTest { buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, htmlConverterProvider = FakeHtmlConverterProvider(), - timelineController = TimelineController(joinedRoom), + timelineController = TimelineController(joinedRoom, timeline), permalinkParser = permalinkParser, encryptionService = encryptionService, analyticsService = analyticsService, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 50dbb2b7ca..d2d187188c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -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.libraries.dateformatter.test.FakeDateFormatter 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.test.AN_EVENT_ID 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_THREAD_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.aRoomInfo @@ -192,7 +195,7 @@ class ActionListPresenterTest { val messageEvent = aMessageEvent( isMine = 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) ) initialState.eventSink.invoke( @@ -426,7 +429,7 @@ class ActionListPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent( isMine = true, - isThreaded = true, + threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) ) initialState.eventSink.invoke( @@ -1240,11 +1243,59 @@ class ActionListPresenterTest { 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( isDeveloperModeEnabled: Boolean, room: BaseRoom = FakeBaseRoom(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, ): ActionListPresenter { val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) return DefaultActionListPresenter( @@ -1253,5 +1304,6 @@ private fun createActionListPresenter( room = room, userSendFailureFactory = VerifiedUserSendFailureFactory(room), dateFormatter = FakeDateFormatter(), + timelineMode = timelineMode, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 2f24322bf9..38535f3bdb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -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.permalink.PermalinkBuilder 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.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder @@ -573,6 +574,7 @@ class AttachmentsPreviewPresenterTest { uri = mockMediaUrl, ), room: JoinedRoom = FakeJoinedRoom(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), @@ -595,14 +597,24 @@ class AttachmentsPreviewPresenterTest { return AttachmentsPreviewPresenter( attachment = aMediaAttachment(localMedia), onDoneListener = onDoneListener, - mediaSender = MediaSender(mediaPreProcessor, room, { - MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) - }), + mediaSenderFactory = object : MediaSender.Factory { + override fun create(timelineMode: Timeline.Mode): MediaSender { + return MediaSender( + preProcessor = mediaPreProcessor, + room = room, + timelineMode = timelineMode, + mediaOptimizationConfigProvider = { + MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) + } + ) + } + }, permalinkBuilder = permalinkBuilder, temporaryUriDeleter = temporaryUriDeleter, sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, + timelineMode = timelineMode, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index a8dbab2cf2..a9f2e075ac 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -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.TransactionId 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.MessageShieldProvider import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider @@ -40,7 +41,7 @@ internal fun aMessageEvent( canBeRepliedTo: Boolean = true, content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), inReplyTo: InReplyToDetails? = null, - isThreaded: Boolean = false, + threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, @@ -61,7 +62,7 @@ internal fun aMessageEvent( readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = sendState, inReplyTo = inReplyTo, - isThreaded = isThreaded, + threadInfo = threadInfo, origin = null, timelineItemDebugInfoProvider = debugInfoProvider, messageShieldProvider = messageShieldProvider, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 4f9d538244..d52cd2e291 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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.draft.ComposerDraft 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.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo @@ -1521,6 +1522,7 @@ class MessageComposerPresenterTest { room: JoinedRoom = FakeJoinedRoom( typingNoticeResult = { Result.success(Unit) } ), + timeline: Timeline = room.liveTimeline, navigator: MessagesNavigator = FakeMessagesNavigator(), pickerProvider: PickerProvider = this@MessageComposerPresenterTest.pickerProvider, locationService: LocationService = FakeLocationService(true), @@ -1546,11 +1548,21 @@ class MessageComposerPresenterTest { mediaPickerProvider = pickerProvider, sessionPreferencesStore = sessionPreferencesStore, localMediaFactory = localMediaFactory, - mediaSender = MediaSender( - preProcessor = mediaPreProcessor, - room = room, - mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) } - ), + mediaSenderFactory = object : MediaSender.Factory { + override fun create(timelineMode: Timeline.Mode): MediaSender { + return MediaSender( + preProcessor = mediaPreProcessor, + room = room, + timelineMode = timelineMode, + mediaOptimizationConfigProvider = { + MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD + ) + } + ) + } + }, snackbarDispatcher = snackbarDispatcher, analyticsService = analyticsService, locationService = locationService, @@ -1560,7 +1572,7 @@ class MessageComposerPresenterTest { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, - timelineController = TimelineController(room), + timelineController = TimelineController(room, timeline), draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 507edf9d9a..07778ab381 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -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.protection.aTimelineProtectionState 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.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService @@ -297,6 +298,7 @@ class PinnedMessagesListPresenterTest { room: JoinedRoom = FakeJoinedRoom(), syncService: SyncService = FakeSyncService(), analyticsService: AnalyticsService = FakeAnalyticsService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ): PinnedMessagesListPresenter { val timelineProvider = PinnedEventsTimelineProvider( room = room, @@ -314,6 +316,7 @@ class PinnedMessagesListPresenterTest { actionListPresenter = { anActionListState() }, linkPresenter = { aLinkState() }, analyticsService = analyticsService, + featureFlagService = featureFlagService, sessionCoroutineScope = this, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index ab183442f2..a2005b7a39 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -33,7 +33,7 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -72,7 +72,7 @@ class TimelineControllerTest { } } ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(joinedRoom, liveTimeline) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -100,7 +100,7 @@ class TimelineControllerTest { val joinedRoom = FakeJoinedRoom( liveTimeline = liveTimeline ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) @@ -119,7 +119,7 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) @@ -147,7 +147,7 @@ class TimelineControllerTest { val joinedRoom = FakeJoinedRoom( liveTimeline = liveTimeline ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) assertThat(sut.timelineItems().first()).hasSize(1) } @@ -169,7 +169,7 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { sut.focusOnEvent(AN_EVENT_ID) awaitItem().also { state -> @@ -194,7 +194,7 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(joinedRoom) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { awaitItem().also { state -> diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 9ba2688d45..8710af8bda 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -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.FakeSendPollResponseAction 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.RoomId import io.element.android.libraries.matrix.api.core.UniqueId @@ -787,6 +788,7 @@ class TimelinePresenterTest { sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -799,11 +801,12 @@ class TimelinePresenterTest { sendPollResponseAction = sendPollResponseAction, sessionPreferencesStore = sessionPreferencesStore, timelineItemIndexer = timelineItemIndexer, - timelineController = TimelineController(room), + timelineController = TimelineController(room, timeline), resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, markAsFullyRead = markAsFullyRead, + featureFlagService = featureFlagService, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 10efc72e71..917683220e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -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.VideoInfo 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.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType @@ -749,14 +750,14 @@ class TimelineItemContentMessageFactoryTest { body: String = "Body", inReplyTo: InReplyTo? = null, isEdited: Boolean = false, - isThreaded: Boolean = false, + threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), type: MessageType, ): MessageContent { return MessageContent( body = body, inReplyTo = inReplyTo, isEdited = isEdited, - isThreaded = isThreaded, + threadInfo = threadInfo, type = type, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index 0bcd6b8091..188c5f0bcd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -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.libraries.designsystem.components.avatar.anAvatarData 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.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID @@ -41,7 +42,7 @@ class TimelineItemGrouperTest { isEditable = false, canBeRepliedTo = false, inReplyTo = null, - isThreaded = false, + threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), origin = null, timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() }, messageShieldProvider = { null }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index 347b2f0dfb..6040032ebc 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -17,10 +17,13 @@ import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.google.common.truth.Truth.assertThat 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.test.FakeMessageComposerContext 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.timeline.Timeline 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.timeline.FakeTimeline @@ -75,6 +78,7 @@ class VoiceMessageComposerPresenterTest { private val mediaSender = MediaSender( preProcessor = mediaPreProcessor, room = joinedRoom, + timelineMode = Timeline.Mode.Live, mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }, ) private val messageComposerContext = FakeMessageComposerContext() @@ -86,7 +90,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -100,7 +104,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - recording state`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -116,7 +120,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - recording keeps screen on`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -140,7 +144,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - abort recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -155,7 +159,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - finish recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -172,7 +176,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - play recording before it is ready`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -191,7 +195,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - play recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -209,7 +213,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - pause recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -228,7 +232,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - seek recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -255,7 +259,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - delete recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -273,7 +277,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - delete while playing`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -295,7 +299,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - send recording`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -314,7 +318,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - sending is tracked`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -343,7 +347,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - send while playing`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -365,7 +369,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - send recording before previous completed, waits`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -390,7 +394,7 @@ class VoiceMessageComposerPresenterTest { fun `present - send failures aren't tracked`() = runTest { // Let sending fail due to media preprocessing error mediaPreProcessor.givenResult(Result.failure(Exception())) - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -414,7 +418,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - send failures can be retried`() = runTest { // Let sending fail due to media preprocessing error - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -443,7 +447,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - send failures are displayed as an error dialog`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -478,7 +482,7 @@ class VoiceMessageComposerPresenterTest { @Test fun `present - send error - missing recording is tracked`() = runTest { - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -499,7 +503,7 @@ class VoiceMessageComposerPresenterTest { fun `present - record error - security exceptions are tracked`() = runTest { val exception = SecurityException("") voiceRecorder.givenThrowsSecurityException(exception) - val presenter = createVoiceMessageComposerPresenter() + val presenter = createDefaultVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -521,7 +525,7 @@ class VoiceMessageComposerPresenterTest { val permissionsPresenter = createFakePermissionsPresenter( recordPermissionGranted = false, ) - val presenter = createVoiceMessageComposerPresenter( + val presenter = createDefaultVoiceMessageComposerPresenter( permissionsPresenter = permissionsPresenter, ) moleculeFlow(RecompositionMode.Immediate) { @@ -550,7 +554,7 @@ class VoiceMessageComposerPresenterTest { val permissionsPresenter = createFakePermissionsPresenter( recordPermissionGranted = false, ) - val presenter = createVoiceMessageComposerPresenter( + val presenter = createDefaultVoiceMessageComposerPresenter( permissionsPresenter = permissionsPresenter, ) moleculeFlow(RecompositionMode.Immediate) { @@ -584,7 +588,7 @@ class VoiceMessageComposerPresenterTest { val permissionsPresenter = createFakePermissionsPresenter( recordPermissionGranted = false, ) - val presenter = createVoiceMessageComposerPresenter( + val presenter = createDefaultVoiceMessageComposerPresenter( permissionsPresenter = permissionsPresenter, ) moleculeFlow(RecompositionMode.Immediate) { @@ -656,17 +660,22 @@ class VoiceMessageComposerPresenterTest { } } - private fun TestScope.createVoiceMessageComposerPresenter( + private fun TestScope.createDefaultVoiceMessageComposerPresenter( permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), - ): VoiceMessageComposerPresenter { - return VoiceMessageComposerPresenter( - backgroundScope, - voiceRecorder, - analyticsService, - mediaSender, + ): DefaultVoiceMessageComposerPresenter { + return DefaultVoiceMessageComposerPresenter( + sessionCoroutineScope = backgroundScope, + timelineMode = Timeline.Mode.Live, + voiceRecorder = voiceRecorder, + analyticsService = analyticsService, + mediaSenderFactory = object : MediaSender.Factory { + override fun create(timelineMode: Timeline.Mode): MediaSender { + return mediaSender + } + }, player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), messageComposerContext = messageComposerContext, - FakePermissionsPresenterFactory(permissionsPresenter), + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), ) } diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts index 5e98674f4e..93f5166f29 100644 --- a/features/messages/test/build.gradle.kts +++ b/features/messages/test/build.gradle.kts @@ -15,7 +15,12 @@ android { dependencies { 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.permissions.test) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.voicerecorder.test) + implementation(projects.services.analytics.test) } diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt new file mode 100644 index 0000000000..da83fa69e2 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -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(), + ) + } +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt index 127719bf27..8e5b5c6ba3 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt @@ -8,7 +8,8 @@ package io.element.android.features.poll.api.actions import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline interface EndPollAction { - suspend fun execute(pollStartId: EventId): Result + suspend fun execute(timeline: Timeline, pollStartId: EventId): Result } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt index 9b1e403e7f..49da04829b 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -8,7 +8,12 @@ package io.element.android.features.poll.api.actions import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline interface SendPollResponseAction { - suspend fun execute(pollStartId: EventId, answerId: String): Result + suspend fun execute( + timeline: Timeline, + pollStartId: EventId, + answerId: String + ): Result } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt index 7b508511ed..348f2f6c60 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt @@ -10,9 +10,11 @@ package io.element.android.features.poll.api.create 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 interface CreatePollEntryPoint : FeatureEntryPoint { data class Params( + val timelineMode: Timeline.Mode, val mode: CreatePollMode, ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt index ea11ef918e..1bb0d87405 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt @@ -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.libraries.di.RoomScope 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 javax.inject.Inject @ContributesBinding(RoomScope::class) class DefaultEndPollAction @Inject constructor( - private val room: JoinedRoom, private val analyticsService: AnalyticsService, ) : EndPollAction { - override suspend fun execute(pollStartId: EventId): Result { - return room.liveTimeline.endPoll( + override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result { + return timeline.endPoll( pollStartId = pollStartId, text = "The poll with event id: $pollStartId has ended." ).onSuccess { diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt index 4f1f29df46..757fe1803e 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt @@ -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.libraries.di.RoomScope 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 javax.inject.Inject @ContributesBinding(RoomScope::class) class DefaultSendPollResponseAction @Inject constructor( - private val room: JoinedRoom, private val analyticsService: AnalyticsService, ) : SendPollResponseAction { - override suspend fun execute(pollStartId: EventId, answerId: String): Result { - return room.liveTimeline.sendPollResponse( + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + return timeline.sendPollResponse( pollStartId = pollStartId, answers = listOf(answerId), ).onSuccess { diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt index 5a86e476a9..1a397b96e6 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt @@ -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.inputs 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 java.util.concurrent.atomic.AtomicBoolean @@ -31,7 +32,7 @@ class CreatePollNode @AssistedInject constructor( presenterFactory: CreatePollPresenter.Factory, analyticsService: AnalyticsService, ) : 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() @@ -44,6 +45,7 @@ class CreatePollNode @AssistedInject constructor( } }, mode = inputs.mode, + timelineMode = inputs.timelineMode, ) init { diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt index f1d9b780ee..f9f8e59ea8 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -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.PollKind 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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -37,17 +38,24 @@ import kotlinx.coroutines.launch import timber.log.Timber class CreatePollPresenter @AssistedInject constructor( - private val repository: PollRepository, + repositoryFactory: PollRepository.Factory, private val analyticsService: AnalyticsService, private val messageComposerContext: MessageComposerContext, @Assisted private val navigateUp: () -> Unit, @Assisted private val mode: CreatePollMode, + @Assisted private val timelineMode: Timeline.Mode, ) : Presenter { @AssistedFactory 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 override fun present(): CreatePollState { // The initial state of the form. In edit mode this will be populated with the poll being edited. diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt index fd0e670fc3..019da10de9 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt @@ -23,7 +23,7 @@ class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint { return object : 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 } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt index 0fbdcdee36..ad73b0583f 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt @@ -7,24 +7,40 @@ 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.matrix.api.core.EventId 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.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.getActiveTimeline import io.element.android.libraries.matrix.api.timeline.item.event.PollContent 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 javax.inject.Inject -class PollRepository @Inject constructor( +class PollRepository @AssistedInject constructor( 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 = runCatchingExceptions { - timelineProvider + getTimelineProvider() + .getOrThrow() .getActiveTimeline() .timelineItems .first() @@ -42,30 +58,51 @@ class PollRepository @Inject constructor( pollKind: PollKind, maxSelections: Int, ): Result = when (existingPollId) { - null -> room.liveTimeline.createPoll( - question = question, - answers = answers, - maxSelections = maxSelections, - pollKind = pollKind, - ) - else -> timelineProvider - .getActiveTimeline() - .editPoll( - pollStartId = existingPollId, - question = question, - answers = answers, - maxSelections = maxSelections, - pollKind = pollKind, - ) + null -> getTimelineProvider().flatMap { timelineProvider -> + timelineProvider + .getActiveTimeline() + .createPoll( + question = question, + answers = answers, + maxSelections = maxSelections, + pollKind = pollKind, + ) + } + else -> getTimelineProvider().flatMap { timelineProvider -> + timelineProvider.getActiveTimeline() + .editPoll( + pollStartId = existingPollId, + question = question, + answers = answers, + maxSelections = maxSelections, + pollKind = pollKind, + ) + } } suspend fun deletePoll( pollStartId: EventId, ): Result = - timelineProvider - .getActiveTimeline() - .redactEvent( - eventOrTransactionId = pollStartId.toEventOrTransactionId(), - reason = null, - ) + getTimelineProvider().flatMap { timelineProvider -> + timelineProvider.getActiveTimeline() + .redactEvent( + eventOrTransactionId = pollStartId.toEventOrTransactionId(), + reason = null, + ) + } + + private suspend fun getTimelineProvider(): Result { + return when (timelineMode) { + is Timeline.Mode.Thread -> { + val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) + threadedTimelineResult.map { threadedTimeline -> + object : TimelineProvider { + private val flow = MutableStateFlow(threadedTimeline) + override fun activeTimelineFlow(): StateFlow = flow + } + } + } + else -> Result.success(defaultTimelineProvider) + } + } } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt index 0f8cc5dc8f..48a222e0e7 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -52,7 +53,12 @@ class PollHistoryFlowNode @AssistedInject constructor( return when (navTarget) { is NavTarget.EditPoll -> { 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() } NavTarget.Root -> { diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index e3d7632265..b144f31609 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -67,10 +67,14 @@ class PollHistoryPresenter @Inject constructor( coroutineScope.loadMore(timeline) } 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 { - endPollAction.execute(pollStartId = event.pollStartId) + endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId) } is PollHistoryEvents.SelectFilter -> { activeFilter = event.filter diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt index 67556886bc..6bec98f1b9 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -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.libraries.matrix.api.core.EventId 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.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId @@ -551,12 +552,18 @@ class CreatePollPresenterTest { private fun createCreatePollPresenter( mode: CreatePollMode = CreatePollMode.NewPoll, room: FakeJoinedRoom = fakeJoinedRoom, + timelineMode: Timeline.Mode = Timeline.Mode.Live, ): 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, messageComposerContext = fakeMessageComposerContext, navigateUp = { navUpInvocationsCount++ }, mode = mode, + timelineMode = timelineMode, ) } diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt index 6da99ad3d8..872110d0b6 100644 --- a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt @@ -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.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline class FakeEndPollAction : EndPollAction { private var executionCount = 0 @@ -17,7 +18,7 @@ class FakeEndPollAction : EndPollAction { assert(executionCount == count) } - override suspend fun execute(pollStartId: EventId): Result { + override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result { executionCount++ return Result.success(Unit) } diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt index 859083a05c..f77d974178 100644 --- a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt @@ -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.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline class FakeSendPollResponseAction : SendPollResponseAction { private var executionCount = 0 @@ -17,7 +18,7 @@ class FakeSendPollResponseAction : SendPollResponseAction { assert(executionCount == count) } - override suspend fun execute(pollStartId: EventId, answerId: String): Result { + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { executionCount++ return Result.success(Unit) } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index ea38b0bc70..b66765f472 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -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.core.RoomId 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.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender @@ -88,6 +89,7 @@ class SharePresenter @AssistedInject constructor( val mediaSender = MediaSender( preProcessor = mediaPreProcessor, room = room, + timelineMode = Timeline.Mode.Live, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) filesToShare diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultBaseRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultBaseRoomLastMessageFormatterTest.kt index 6d0ad1aa49..ee56fcf2c5 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultBaseRoomLastMessageFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultBaseRoomLastMessageFormatterTest.kt @@ -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.media.ImageInfo 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.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EventContent @@ -174,7 +175,7 @@ class DefaultBaseRoomLastMessageFormatterTest { ) { val body = "Shared body" fun createMessageContent(type: MessageType): MessageContent { - return MessageContent(body, null, false, false, type) + return MessageContent(body, null, false, EventThreadInfo(null, null), type) } val sharedContentMessagesTypes = arrayOf( diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index 4ff71608f8..349cd2585f 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -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.media.ImageInfo 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.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EventContent @@ -129,7 +130,7 @@ class DefaultPinnedMessagesBannerFormatterTest { fun `Message contents`() { val body = "Shared body" fun createMessageContent(type: MessageType): MessageContent { - return MessageContent(body, null, false, false, type) + return MessageContent(body, null, false, EventThreadInfo(null, null), type) } val sharedContentMessagesTypes = arrayOf( diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 9d34c03a93..7c87a6e034 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -93,4 +93,11 @@ enum class FeatureFlags( // False so it's displayed in the developer options screen 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, + ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt index 3dc5801d6b..08a9c2eeaa 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt @@ -20,3 +20,5 @@ value class EventId(val value: String) : Serializable { override fun toString(): String = value } + +fun EventId.toThreadId(): ThreadId = ThreadId(value) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt index 0bcfb0bf54..7d7b3a379a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt @@ -8,10 +8,12 @@ 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.ThreadId sealed interface CreateTimelineParams { data class Focused(val focusedEventId: EventId) : CreateTimelineParams data object MediaOnly : CreateTimelineParams data class MediaOnlyFocused(val focusedEventId: EventId) : CreateTimelineParams data object PinnedOnly : CreateTimelineParams + data class Threaded(val threadRootEventId: ThreadId) : CreateTimelineParams } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 4e7b64e1d5..4eceeac4da 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -7,8 +7,10 @@ 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.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.media.AudioInfo 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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize import java.io.File interface Timeline : AutoCloseable { @@ -38,13 +41,16 @@ interface Timeline : AutoCloseable { FORWARDS } - enum class Mode { - LIVE, - FOCUSED_ON_EVENT, - PINNED_EVENTS, - MEDIA, + @Parcelize + sealed interface Mode : Parcelable { + data object Live : Mode + data class FocusedOnEvent(val eventId: EventId) : Mode + data object PinnedEvents : Mode + data object Media : Mode + data class Thread(val threadRootId: ThreadId) : Mode } + val mode: Mode val membershipChangeEventReceived: Flow suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result suspend fun paginate(direction: PaginationDirection): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/ThreadSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/ThreadSummary.kt new file mode 100644 index 0000000000..4960330448 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/ThreadSummary.kt @@ -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, + val numberOfReplies: Long, +) + +data class EmbeddedEventInfo( + val eventOrTransactionId: EventOrTransactionId, + val content: EventContent, + val senderId: UserId, + val senderProfile: ProfileTimelineDetails, + val timestamp: Long, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index bf062b76e0..a6bea83565 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -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.poll.PollAnswer 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.ImmutableMap @@ -23,7 +24,7 @@ data class MessageContent( val body: String, val inReplyTo: InReplyTo?, val isEdited: Boolean, - val isThreaded: Boolean, + val threadInfo: EventThreadInfo, val type: MessageType ) : EventContent diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 0438df4032..fc08c1cee1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -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.TransactionId 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 kotlinx.collections.immutable.ImmutableList @@ -37,9 +38,7 @@ data class EventTimelineItem( return (content as? MessageContent)?.inReplyTo } - fun isThreaded(): Boolean { - return (content as? MessageContent)?.isThreaded ?: false - } + fun threadInfo(): EventThreadInfo? = (content as? MessageContent)?.threadInfo fun hasNotLoadedInReplyTo(): Boolean { val details = inReplyTo() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index f2e2270019..b0ac21b19d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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.extensions.mapFailure 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.core.DeviceId import io.element.android.libraries.matrix.api.core.RoomAlias @@ -133,6 +134,7 @@ class RustMatrixClient( baseCacheDirectory: File, clock: SystemClock, timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, + private val featureFlagService: FeatureFlagService, ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) @@ -203,6 +205,7 @@ class RustMatrixClient( timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, roomMembershipObserver = roomMembershipObserver, roomInfoMapper = roomInfoMapper, + featureFlagService = featureFlagService, ) override val mediaLoader: MatrixMediaLoader = RustMediaLoader( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 2a4abe6dc2..4ad26800cd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -93,6 +93,7 @@ class RustMatrixClientFactory @Inject constructor( baseCacheDirectory = cacheDirectory, clock = clock, timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, + featureFlagService = featureFlagService, ).also { 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)) + .threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents), threadSubscriptions = false) .run { // Apply sliding sync version settings when (slidingSyncType) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 5edff19d84..c476c34069 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -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.extensions.mapFailure 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.EventId import io.element.android.libraries.matrix.api.core.RoomAlias @@ -83,6 +85,7 @@ class JoinedRustRoom( private val coroutineDispatchers: CoroutineDispatchers, private val systemClock: SystemClock, private val roomContentForwarder: RoomContentForwarder, + private val featureFlagService: FeatureFlagService, ) : JoinedRoom, BaseRoom by baseRoom { // Create a dispatcher for all room methods... private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) @@ -132,7 +135,7 @@ class JoinedRustRoom( override val roomNotificationSettingsStateFlow = MutableStateFlow(RoomNotificationSettingsState.Unknown) - override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.LIVE) { + override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) { syncUpdateFlow.value = systemClock.epochMillis() } @@ -153,22 +156,27 @@ class JoinedRustRoom( override suspend fun createTimeline( createTimelineParams: CreateTimelineParams, ): Result = withContext(roomDispatcher) { + val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents) val focus = when (createTimelineParams) { is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents( maxEventsToLoad = 100u, maxConcurrentRequests = 10u, ) - is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = false) + is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents) is CreateTimelineParams.Focused -> TimelineFocus.Event( eventId = createTimelineParams.focusedEventId.value, numContextEvents = 50u, - hideThreadedEvents = false, + hideThreadedEvents = hideThreadedEvents, ) is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event( eventId = createTimelineParams.focusedEventId.value, numContextEvents = 50u, + // Never hide threaded events in media focused timeline hideThreadedEvents = false, ) + is CreateTimelineParams.Threaded -> TimelineFocus.Thread( + rootEventId = createTimelineParams.threadRootEventId.value, + ) } val filter = when (createTimelineParams) { @@ -182,7 +190,8 @@ class JoinedRustRoom( ) ) is CreateTimelineParams.Focused, - CreateTimelineParams.PinnedOnly -> TimelineFilter.All + CreateTimelineParams.PinnedOnly, + is CreateTimelineParams.Threaded -> TimelineFilter.All } val internalIdPrefix = when (createTimelineParams) { @@ -190,6 +199,7 @@ class JoinedRustRoom( is CreateTimelineParams.Focused -> "focus_${createTimelineParams.focusedEventId}" is CreateTimelineParams.MediaOnly -> "MediaGallery_" is CreateTimelineParams.MediaOnlyFocused -> "MediaGallery_${createTimelineParams.focusedEventId}" + is CreateTimelineParams.Threaded -> "Thread_${createTimelineParams.threadRootEventId}" } // Note that for TimelineFilter.MediaOnlyFocused, the date separator will be filtered out, @@ -198,7 +208,8 @@ class JoinedRustRoom( is CreateTimelineParams.MediaOnly, is CreateTimelineParams.MediaOnlyFocused -> DateDividerMode.MONTHLY is CreateTimelineParams.Focused, - CreateTimelineParams.PinnedOnly -> DateDividerMode.DAILY + CreateTimelineParams.PinnedOnly, + is CreateTimelineParams.Threaded -> DateDividerMode.DAILY } // Track read receipts only for focused timeline for performance optimization @@ -216,17 +227,19 @@ class JoinedRustRoom( ) ).let { innerTimeline -> val mode = when (createTimelineParams) { - is CreateTimelineParams.Focused -> Timeline.Mode.FOCUSED_ON_EVENT - is CreateTimelineParams.MediaOnly -> Timeline.Mode.MEDIA - is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FOCUSED_ON_EVENT - CreateTimelineParams.PinnedOnly -> Timeline.Mode.PINNED_EVENTS + is CreateTimelineParams.Focused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId) + is CreateTimelineParams.MediaOnly -> Timeline.Mode.Media + is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId) + CreateTimelineParams.PinnedOnly -> Timeline.Mode.PinnedEvents + is CreateTimelineParams.Threaded -> Timeline.Mode.Thread(createTimelineParams.threadRootEventId) } innerTimeline.map(mode = mode) } }.mapFailure { when (createTimelineParams) { is CreateTimelineParams.Focused, - is CreateTimelineParams.MediaOnlyFocused -> it.toFocusEventException() + is CreateTimelineParams.MediaOnlyFocused, + is CreateTimelineParams.Threaded -> it.toFocusEventException() CreateTimelineParams.MediaOnly, CreateTimelineParams.PinnedOnly -> it } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt index e53ac5c478..db681416de 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -9,6 +9,8 @@ package io.element.android.libraries.matrix.impl.room import io.element.android.appconfig.TimelineConfig 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.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -48,6 +50,7 @@ class RustRoomFactory( private val innerRoomListService: InnerRoomListService, private val roomSyncSubscriber: RoomSyncSubscriber, private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, + private val featureFlagService: FeatureFlagService, private val roomMembershipObserver: RoomMembershipObserver, private val roomInfoMapper: RoomInfoMapper, ) { @@ -105,10 +108,11 @@ class RustRoomFactory( val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null if (sdkRoom.membership() == Membership.JOINED) { + val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents) // Init the live timeline in the SDK from the Room val timeline = sdkRoom.timelineWithConfiguration( TimelineConfiguration( - focus = TimelineFocus.Live(hideThreadedEvents = false), + focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, internalIdPrefix = "live", dateDividerMode = DateDividerMode.DAILY, @@ -125,6 +129,7 @@ class RustRoomFactory( liveInnerTimeline = timeline, coroutineDispatchers = dispatchers, systemClock = systemClock, + featureFlagService = featureFlagService, ) ) } else { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index b3767d821c..ee60a464e7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -79,7 +79,7 @@ private const val PAGINATION_SIZE = 50 class RustTimeline( private val inner: InnerTimeline, - mode: Timeline.Mode, + override val mode: Timeline.Mode, systemClock: SystemClock, private val joinedRoom: JoinedRoom, private val coroutineScope: CoroutineScope, @@ -118,19 +118,20 @@ class RustTimeline( private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode) 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( - Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT) + Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode !is Timeline.Mode.FocusedOnEvent) ) init { - if (mode != Timeline.Mode.PINNED_EVENTS) { - coroutineScope.fetchMembers() + when (mode) { + 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 // sdk can automatically paginate backwards. coroutineScope.registerBackPaginationStatusListener() @@ -219,6 +220,7 @@ class RustTimeline( items = items, hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad, hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad, + timelineMode = mode, ) } .let { items -> diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index d806c9492f..aa20ffd267 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -7,6 +7,7 @@ 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.EmoteMessageType 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 { 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 inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map) MessageContent( body = it.content.body, inReplyTo = inReplyToEvent, isEdited = it.content.isEdited, - isThreaded = isThreaded, + threadInfo = threadInfo, type = type ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventOrTransactionIdExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventOrTransactionIdExtension.kt new file mode 100644 index 0000000000..d247bb72d0 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventOrTransactionIdExtension.kt @@ -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)) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index a62f18ddd1..eeb063b28a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -7,7 +7,12 @@ 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.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.EventContent 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 kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap +import org.matrix.rustcomponents.sdk.EmbeddedEventDetails import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.use @@ -59,8 +65,35 @@ class TimelineEventContentMapper( when (val kind = it.content.kind) { is MsgLikeKind.Message -> { val inReplyTo = it.content.inReplyTo - val isThreaded = it.content.threadRoot != null - eventMessageMapper.map(kind, inReplyTo, isThreaded) + val threadSummary = it.content.threadSummary?.use { summary -> + 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 -> { RedactedContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt index 9be7dddddb..c3e8237b72 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt @@ -24,7 +24,7 @@ class LastForwardIndicatorsPostProcessor( items: List, ): List { // 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 } else { return buildList { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt index 6d42af54b5..22b70b1d35 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt @@ -18,8 +18,9 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) { items: List, hasMoreToLoadBackward: Boolean, hasMoreToLoadForward: Boolean, + timelineMode: Timeline.Mode, ): List { - val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty() + val shouldAddForwardLoadingIndicator = timelineMode is Timeline.Mode.Live && hasMoreToLoadForward && items.isNotEmpty() val currentTimestamp = systemClock.epochMillis() return buildList { if (hasMoreToLoadBackward) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt index 8403f63188..639f1b879f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt @@ -28,7 +28,7 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) { ): List { return when { items.isEmpty() -> items - mode == Timeline.Mode.PINNED_EVENTS -> items + mode == Timeline.Mode.PinnedEvents -> items isDm -> processForDM(items, roomCreator) hasMoreToLoadBackwards -> items else -> processForRoom(items) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt index b244aa229c..4c888fa7f2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt @@ -17,7 +17,7 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime */ class TypingNotificationPostProcessor(private val mode: Timeline.Mode) { fun process(items: List): List { - return if (mode == Timeline.Mode.LIVE) { + return if (mode is Timeline.Mode.Live) { buildList { addAll(items) add( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index cb316452c5..91d0f06d1c 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.impl 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.FakeFfiSyncService import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory @@ -66,5 +67,6 @@ class RustMatrixClientTest { baseCacheDirectory = File(""), clock = FakeSystemClock(), timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), + featureFlagService = FakeFeatureFlagService(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt index 911219cac3..6e0c73b350 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt @@ -39,6 +39,7 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) { override fun userAgent(userAgent: String) = this override fun username(username: String) = this override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this + override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this override suspend fun build(): Client { return FakeFfiClient(withUtdHook = {}) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt index 2819dd6ac7..8bcd56978e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt @@ -90,7 +90,7 @@ class RustTimelineTest { private fun TestScope.createRustTimeline( inner: InnerTimeline, - mode: Timeline.Mode = Timeline.Mode.LIVE, + mode: Timeline.Mode = Timeline.Mode.Live, systemClock: SystemClock = FakeSystemClock(), joinedRoom: JoinedRoom = FakeJoinedRoom().apply { givenRoomInfo(aRoomInfo()) }, coroutineScope: CoroutineScope = backgroundScope, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt index c5b1c3edb7..0d2dd743f5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt @@ -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.Timeline 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 class LastForwardIndicatorsPostProcessorTest { @Test 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)) assertThat(result).containsExactly(messageEvent) } @Test 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)) assertThat(result).containsExactly( messageEvent, @@ -37,7 +38,7 @@ class LastForwardIndicatorsPostProcessorTest { @Test 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()) assertThat(result).containsExactly( MatrixTimelineItem.Virtual( @@ -49,7 +50,7 @@ class LastForwardIndicatorsPostProcessorTest { @Test 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 sut.process(listOf(messageEvent)) // Process a second time with the same Event @@ -65,7 +66,7 @@ class LastForwardIndicatorsPostProcessorTest { @Test 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 sut.process(listOf(dayEvent, messageEvent)) // Process a second time with the same Event diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt index 881d718392..c92fb9776b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt @@ -24,6 +24,7 @@ class LoadingIndicatorsPostProcessorTest { items = listOf(messageEvent, messageEvent2), hasMoreToLoadBackward = true, hasMoreToLoadForward = false, + timelineMode = Timeline.Mode.Live, ) assertThat(result).containsExactly( MatrixTimelineItem.Virtual( @@ -46,6 +47,7 @@ class LoadingIndicatorsPostProcessorTest { items = listOf(messageEvent, messageEvent2), hasMoreToLoadBackward = false, hasMoreToLoadForward = true, + timelineMode = Timeline.Mode.Live, ) assertThat(result).containsExactly( messageEvent, @@ -68,6 +70,7 @@ class LoadingIndicatorsPostProcessorTest { items = listOf(messageEvent, messageEvent2), hasMoreToLoadBackward = true, hasMoreToLoadForward = true, + timelineMode = Timeline.Mode.Live, ) assertThat(result).containsExactly( MatrixTimelineItem.Virtual( @@ -97,6 +100,7 @@ class LoadingIndicatorsPostProcessorTest { items = listOf(), hasMoreToLoadBackward = true, hasMoreToLoadForward = true, + timelineMode = Timeline.Mode.Live, ) assertThat(result).containsExactly( MatrixTimelineItem.Virtual( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt index ba0bef4266..bfb3660fc6 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt @@ -15,7 +15,7 @@ import org.junit.Test class RoomBeginningPostProcessorTest { @Test fun `processor returns empty list when empty list is provided`() { - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process( items = emptyList(), isDm = true, @@ -27,7 +27,7 @@ class RoomBeginningPostProcessorTest { @Test fun `processor returns the provided list when it only contains a message`() { - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process( items = listOf(messageEvent), isDm = true, @@ -39,7 +39,7 @@ class RoomBeginningPostProcessorTest { @Test fun `processor returns the provided list when it only contains a message and the roomCreator is not provided`() { - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process( items = listOf(messageEvent), isDm = true, @@ -56,7 +56,7 @@ class RoomBeginningPostProcessorTest { roomCreateEvent, roomCreatorJoinEvent, ) - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process( items = timelineItems, isDm = true, @@ -72,7 +72,7 @@ class RoomBeginningPostProcessorTest { roomCreateEvent, roomCreatorJoinEvent, ) - val processor = RoomBeginningPostProcessor(Timeline.Mode.PINNED_EVENTS) + val processor = RoomBeginningPostProcessor(Timeline.Mode.PinnedEvents) val processedItems = processor.process( items = timelineItems, isDm = true, @@ -94,7 +94,7 @@ class RoomBeginningPostProcessorTest { otherMemberJoinEvent, messageEvent, ) - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false) assertThat(processedItems).isEqualTo(expected) } @@ -105,7 +105,7 @@ class RoomBeginningPostProcessorTest { roomCreateEvent, roomCreatorJoinEvent, ) - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) assertThat(processedItems).isEmpty() } @@ -115,7 +115,7 @@ class RoomBeginningPostProcessorTest { val timelineItems = listOf( roomCreatorJoinEvent, ) - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) assertThat(processedItems).isEmpty() } @@ -126,7 +126,7 @@ class RoomBeginningPostProcessorTest { roomCreateEvent, otherMemberJoinEvent, ) - val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) assertThat(processedItems).isEqualTo(listOf(otherMemberJoinEvent)) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index ad5505e426..42a0ecbcb6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -48,6 +48,7 @@ class FakeTimeline( ), override val membershipChangeEventReceived: Flow = MutableSharedFlow(), private val cancelSendResult: (TransactionId) -> Result = { lambdaError() }, + override val mode: Timeline.Mode = Timeline.Mode.Live, ) : Timeline { var sendMessageLambda: ( body: String, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index 0aa324eea6..a082f6c3e1 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -14,6 +14,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.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollKind +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.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction @@ -103,7 +104,7 @@ fun aMessageContent( body: String = "body", inReplyTo: InReplyTo? = null, isEdited: Boolean = false, - isThreaded: Boolean = false, + threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), messageType: MessageType = TextMessageType( body = body, formatted = null @@ -112,7 +113,7 @@ fun aMessageContent( body = body, inReplyTo = inReplyTo, isEdited = isEdited, - isThreaded = isThreaded, + threadInfo = threadInfo, type = messageType ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index be68cd920f..70803c5653 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -12,6 +12,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.media.MediaSource import io.element.android.libraries.matrix.api.poll.PollKind +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.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EventContent @@ -133,11 +134,15 @@ class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() { private fun aMessageContent( body: String, type: MessageType, + threadInfo: EventThreadInfo = EventThreadInfo( + threadRootId = null, + threadSummary = null, + ), ) = MessageContent( body = body, inReplyTo = null, isEdited = false, - isThreaded = false, + threadInfo = threadInfo, type = type, ) diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt index 6fdff3ad1c..5b5df0e9b7 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.ui.messages.reply import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange @@ -69,7 +70,7 @@ class InReplyToDetailTest { body = "**Hello!**", inReplyTo = null, isEdited = false, - isThreaded = false, + threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), type = TextMessageType( body = "**Hello!**", formatted = FormattedBody( @@ -94,7 +95,7 @@ class InReplyToDetailTest { body = "**Hello!**", inReplyTo = null, isEdited = false, - isThreaded = false, + threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null), type = TextMessageType( body = "**Hello!**", formatted = null, diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 9d893cc1b6..e11ec804cd 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -8,21 +8,33 @@ package io.element.android.libraries.mediaupload.api import android.net.Uri +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.flatMapCatching import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaUploadHandler +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.timeline.Timeline import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -class MediaSender @Inject constructor( +class MediaSender @AssistedInject constructor( private val preProcessor: MediaPreProcessor, private val room: JoinedRoom, + @Assisted private val timelineMode: Timeline.Mode, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) { + @AssistedFactory + interface Factory { + fun create( + timelineMode: Timeline.Mode, + ): MediaSender + } + private val ongoingUploadJobs = ConcurrentHashMap() val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() @@ -46,12 +58,14 @@ class MediaSender @Inject constructor( formattedCaption: String?, inReplyToEventId: EventId?, ): Result { - return room.liveTimeline.sendMedia( - uploadInfo = mediaUploadInfo, - caption = caption, - formattedCaption = formattedCaption, - inReplyToEventId = inReplyToEventId, - ) + return getTimeline().flatMap { + it.sendMedia( + uploadInfo = mediaUploadInfo, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } .handleSendResult() } @@ -71,7 +85,7 @@ class MediaSender @Inject constructor( mediaOptimizationConfig = mediaOptimizationConfig, ) .flatMapCatching { info -> - room.liveTimeline.sendMedia( + getTimeline().getOrThrow().sendMedia( uploadInfo = info, caption = caption, formattedCaption = formattedCaption, @@ -101,7 +115,7 @@ class MediaSender @Inject constructor( audioInfo = audioInfo, waveform = waveForm, ) - room.liveTimeline.sendMedia( + getTimeline().getOrThrow().sendMedia( uploadInfo = newInfo, caption = null, formattedCaption = null, @@ -186,6 +200,15 @@ class MediaSender @Inject constructor( } } + private suspend fun getTimeline(): Result { + return when (timelineMode) { + is Timeline.Mode.Thread -> { + room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId)) + } + else -> Result.success(room.liveTimeline) + } + } + /** * Clean up any temporary files or resources used during the media processing. */ diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt index 6978708a61..e73bff4d7f 100644 --- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt +++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo 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.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline @@ -160,6 +161,7 @@ class MediaSenderTest { ) = MediaSender( preProcessor = preProcessor, room = room, + timelineMode = Timeline.Mode.Live, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt index f862788c30..5088616a18 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt @@ -97,9 +97,9 @@ class MediaGalleryRootNode @AssistedInject constructor( val mode = when (item) { is MediaItem.Audio, is MediaItem.Voice, - is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.MEDIA) + is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media) is MediaItem.Image, - is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.MEDIA) + is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) } overlay.show( NavTarget.MediaViewer( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 2da3e1c3f2..c5bca59db7 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -82,8 +82,9 @@ class MediaViewerNode @AssistedInject constructor( } when (timelineMode) { null -> timelineMediaGalleryDataSource - Timeline.Mode.LIVE, - Timeline.Mode.FOCUSED_ON_EVENT -> { + Timeline.Mode.Live, + is Timeline.Mode.FocusedOnEvent, + is Timeline.Mode.Thread -> { // Does timelineMediaGalleryDataSource knows the eventId? val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() val isEventKnown = lastData?.hasEvent(eventId) == true @@ -97,14 +98,14 @@ class MediaViewerNode @AssistedInject constructor( ) } } - Timeline.Mode.PINNED_EVENTS -> { + Timeline.Mode.PinnedEvents -> { focusedTimelineMediaGalleryDataSourceFactory.createFor( eventId = eventId, mediaItem = inputs.toMediaItem(), onlyPinnedEvents = true, ) } - Timeline.Mode.MEDIA -> timelineMediaGalleryDataSource + Timeline.Mode.Media -> timelineMediaGalleryDataSource } } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt index 6eced261b1..43a37e8c4c 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -137,7 +137,7 @@ class MediaViewerDataSourceTest { fun `test dataFlow with data galleryMode image`() = runTest { val galleryDataSource = FakeMediaGalleryDataSource() val sut = createMediaViewerDataSource( - mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA), + mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), galleryDataSource = galleryDataSource, ) sut.dataFlow().test { @@ -159,7 +159,7 @@ class MediaViewerDataSourceTest { fun `test dataFlow with data galleryMode files`() = runTest { val galleryDataSource = FakeMediaGalleryDataSource() val sut = createMediaViewerDataSource( - mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA), + mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), galleryDataSource = galleryDataSource, ) sut.dataFlow().test { @@ -265,7 +265,7 @@ class MediaViewerDataSourceTest { } private fun TestScope.createMediaViewerDataSource( - mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA), + mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(), mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl), diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index a4f72aa474..e01eb53efb 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -528,7 +528,7 @@ class MediaViewerPresenterTest { @Test fun `present - snackbar displayed when there is no more items forward images and videos`() { `present - snackbar displayed when there is no more items forward`( - mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA), + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, ) } @@ -536,7 +536,7 @@ class MediaViewerPresenterTest { @Test fun `present - snackbar displayed when there is no more items forward files and audio`() { `present - snackbar displayed when there is no more items forward`( - mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA), + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, ) } @@ -599,7 +599,7 @@ class MediaViewerPresenterTest { @Test fun `present - snackbar displayed when there is no more items backward images and videos`() { `present - snackbar displayed when there is no more items backward`( - mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA), + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, ) } @@ -607,7 +607,7 @@ class MediaViewerPresenterTest { @Test fun `present - snackbar displayed when there is no more items backward files and audio`() { `present - snackbar displayed when there is no more items backward`( - mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA), + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt index 002fefec62..bb9670de39 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt @@ -49,7 +49,7 @@ sealed interface MessageComposerMode { get() = this is Reply && replyToDetails is InReplyToDetails.Ready && replyToDetails.eventContent is MessageContent && - (replyToDetails.eventContent as MessageContent).isThreaded + (replyToDetails.eventContent as MessageContent).threadInfo.threadRootId != null } fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean { diff --git a/services/analytics/test/build.gradle.kts b/services/analytics/test/build.gradle.kts index 0103c99816..01b0544694 100644 --- a/services/analytics/test/build.gradle.kts +++ b/services/analytics/test/build.gradle.kts @@ -13,7 +13,7 @@ android { } dependencies { - implementation(projects.services.analytics.api) + api(projects.services.analytics.api) implementation(projects.libraries.core) implementation(projects.tests.testutils) implementation(libs.coroutines.core) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 7817b59a70..0fec5210d6 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -122,6 +122,7 @@ class KonsistPreviewTest { "TimelineItemEventRowWithManyReactionsPreview", "TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithReplyPreview", + "TimelineItemEventRowWithThreadSummaryPreview", "TimelineItemGroupedEventsRowContentCollapsePreview", "TimelineItemGroupedEventsRowContentExpandedPreview", "TimelineItemImageViewHideMediaContentPreview", diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_en.png new file mode 100644 index 0000000000..4f8761e4dd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddaa247eea635d7e8c079ae128983fd25c1aeba96a48b08ae71218ef52e88d5b +size 69475 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Night_0_en.png new file mode 100644 index 0000000000..f17dbbe01e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:647365688fd0c542d4be6dae570b33fbb7bab6261dc57791db85966808d440bb +size 67563 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_15_en.png new file mode 100644 index 0000000000..ff317ac8ad --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_15_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:045e0d9605be905e4bbc5d88fc5cfa3bd4a109164709ce5276dedc3dbcf2da80 +size 51119 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_15_en.png new file mode 100644 index 0000000000..7fb660b0fb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_15_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdb2ca59f48d38d8fa7ebeb9d5768b44f216266a27f6cec9d518b729ecece253 +size 50253