Threads - first iteration (#5165)
* Initial threads support: parse `ThreadSummary`. Replace several `isThreaded` values with `EventThreadInfo`, which contains the info about the event either being the root of a thread or part of it. * Add `Threaded` timeline mode * Add a `liveTimeline` parameter to `TimelineController`'s constructor. This way we can customise which timeline will be used as the 'live' one. Also add `@LiveTimeline` DI qualifier for the actual live timeline of the room. * Create `ThreadedMessagesNode`. Allow opening a thread in a separate screen. * Add the callbacks for the list menu actions - even if they're the wrong ones and will send the data to the room instead * Send attachments and location in threads * Fix polls in threads, add support for sending voice messages in threads * Display thread summaries only when the feature flag is enabled * Use 'Reply' instead of 'Reply in thread' when in threaded timeline mode * Remove incorrect usage of `Timeline` in `MessageComposerPresenter`. This led to replies to threaded events not appearing as actual replies. --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
cc10ba41fd
commit
35928e3630
119 changed files with 1520 additions and 339 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SendLocationNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(SendLocationNode.Inputs(timelineMode))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Plugin>,
|
||||
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<Inputs>().timelineMode)
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
|
|
|
|||
|
|
@ -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<SendLocationState> {
|
||||
@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<Timeline> {
|
||||
return when (timelineMode) {
|
||||
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
|
||||
else -> Result.success(room.liveTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri()
|
||||
|
|
|
|||
|
|
@ -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<String>): PermissionsPresenter = fakePermissionsPresenter
|
||||
},
|
||||
room = joinedRoom,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
analyticsService = fakeAnalyticsService,
|
||||
messageComposerContext = fakeMessageComposerContext,
|
||||
locationActions = fakeLocationActions,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.api.timeline.voicemessages.composer
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
fun interface VoiceMessageComposerPresenter : Presenter<VoiceMessageComposerState> {
|
||||
interface Factory {
|
||||
fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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<VoiceMessageComposerState> {
|
||||
open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
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,
|
||||
|
|
@ -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<MessagesEntryPoint.Callback>()
|
||||
|
|
@ -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<Attachment>) {
|
||||
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<MessagesNode>(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<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.LocationViewer -> {
|
||||
|
|
@ -327,24 +342,34 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
|
||||
createNode<ReportMessageNode>(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<Attachment>) {
|
||||
backstack.push(NavTarget.AttachmentPreview(
|
||||
attachment = attachments.first(),
|
||||
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId)
|
||||
))
|
||||
}
|
||||
|
||||
override fun onUserDataClick(userId: UserId) {
|
||||
callbacks.forEach { it.onUserDataClick(userId) }
|
||||
}
|
||||
|
||||
override fun onPermalinkClick(data: PermalinkData) {
|
||||
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = true) }
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun onForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
|
||||
}
|
||||
|
||||
override fun onReportMessage(eventId: EventId, senderId: UserId) {
|
||||
backstack.push(NavTarget.ReportMessage(eventId, senderId))
|
||||
}
|
||||
|
||||
override fun onSendLocationClick() {
|
||||
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
|
||||
}
|
||||
|
||||
override fun onCreatePollClick() {
|
||||
backstack.push(NavTarget.CreatePoll(Timeline.Mode.Thread(navTarget.threadRootId)))
|
||||
}
|
||||
|
||||
override fun onEditPollClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
|
||||
}
|
||||
|
||||
override fun onJoinCallClick(roomId: RoomId) {
|
||||
val callType = CallType.RoomCall(
|
||||
sessionId = matrixClient.sessionId,
|
||||
roomId = roomId,
|
||||
)
|
||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
|
||||
elementCallEntryPoint.startCall(callType)
|
||||
}
|
||||
}
|
||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Attachment>)
|
||||
fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>)
|
||||
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Plugin>,
|
||||
@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<Callback>()
|
||||
|
||||
|
|
@ -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<Attachment>)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<MessageComposerState>,
|
||||
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
|
||||
voiceMessageComposerPresenterFactory: DefaultVoiceMessageComposerPresenter.Factory,
|
||||
@Assisted private val timelinePresenter: Presenter<TimelineState>,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||
|
|
@ -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<MessageComposerState>,
|
||||
timelinePresenter: Presenter<TimelineState>,
|
||||
actionListPresenter: Presenter<ActionListState>,
|
||||
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<UserEventPermissions> {
|
||||
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
|
||||
private fun userEventPermissions(roomInfo: RoomInfo): State<UserEventPermissions> {
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<MessagesState> {
|
|||
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()),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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<ActionListState> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<PinnedMessagesBannerState>
|
||||
|
||||
|
|
@ -51,9 +49,6 @@ interface MessagesModule {
|
|||
@Binds
|
||||
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
|
||||
|
||||
@Binds
|
||||
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
|
||||
|
||||
@Binds
|
||||
fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter<CustomReactionState>
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<MessageComposerState> {
|
||||
@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<Suggestion?>(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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Callback>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PinnedMessagesListState> {
|
||||
@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<ImmutableList<TimelineItem>>>(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<ImmutableList<TimelineItem>>,
|
||||
|
|
@ -246,6 +253,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
|||
PinnedMessagesListState.Filled(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
linkState = linkState,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = timelineItems.data,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ sealed interface PinnedMessagesListState {
|
|||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val actionListState: ActionListState,
|
||||
val linkState: LinkState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val eventSink: (PinnedMessagesListEvents) -> Unit,
|
||||
) : PinnedMessagesListState {
|
||||
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ fun aLoadedPinnedMessagesListState(
|
|||
timelineItems: List<TimelineItem> = 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = { _, _ -> },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.threads
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.MessagesPresenter
|
||||
import io.element.android.features.messages.impl.MessagesView
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class ThreadedMessagesNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val room: JoinedRoom,
|
||||
private val analyticsService: AnalyticsService,
|
||||
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
|
||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||
presenterFactory: MessagesPresenter.Factory,
|
||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
||||
data class Inputs(
|
||||
val threadRootEventId: ThreadId,
|
||||
val focusedEventId: EventId?,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
// TODO use a loading state node to preload this instead of using `runBlocking`
|
||||
private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() }
|
||||
private val timelineController = TimelineController(room, threadedTimeline)
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
|
||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||
// TODO add special processor for threaded timeline
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
postProcessor = TimelineItemActionPostProcessor.Default,
|
||||
timelineMode = timelineController.mainTimelineMode(),
|
||||
),
|
||||
timelineController = timelineController,
|
||||
)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
|
||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||
fun onUserDataClick(userId: UserId)
|
||||
fun onPermalinkClick(data: PermalinkData)
|
||||
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun onForwardEventClick(eventId: EventId)
|
||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun onSendLocationClick()
|
||||
fun onCreatePollClick()
|
||||
fun onEditPollClick(eventId: EventId)
|
||||
fun onJoinCallClick(roomId: RoomId)
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
|
||||
},
|
||||
onDestroy = {
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
|
||||
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
|
||||
// - if callbacks is empty, it will return true and we want to return false.
|
||||
// - if a callback returns false, the other callback will not be invoked.
|
||||
return callbacks.takeIf { it.isNotEmpty() }
|
||||
?.map { it.onEventClick(timelineMode, event) }
|
||||
?.all { it }
|
||||
.orFalse()
|
||||
}
|
||||
|
||||
private fun onUserDataClick(userId: UserId) {
|
||||
callbacks.forEach { it.onUserDataClick(userId) }
|
||||
}
|
||||
|
||||
private fun onLinkClick(
|
||||
activity: Activity,
|
||||
darkTheme: Boolean,
|
||||
url: String,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
customTab: Boolean
|
||||
) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
// Open the room member profile, it will fallback to
|
||||
// the user profile if the user is not in the room
|
||||
callbacks.forEach { it.onUserDataClick(permalink.userId) }
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
handleRoomLinkClick(permalink, eventSink)
|
||||
}
|
||||
is PermalinkData.FallbackLink -> {
|
||||
if (customTab) {
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, url)
|
||||
} else {
|
||||
activity.openUrlInExternalApp(url)
|
||||
}
|
||||
}
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClick(
|
||||
roomLink: PermalinkData.RoomLink,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
val eventId = roomLink.eventId
|
||||
if (eventId != null) {
|
||||
eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
} else {
|
||||
// Click on the same room, ignore
|
||||
displaySameRoomToast()
|
||||
}
|
||||
} else {
|
||||
callbacks.forEach { it.onPermalinkClick(roomLink) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
|
||||
}
|
||||
|
||||
override fun onForwardEventClick(eventId: EventId) {
|
||||
callbacks.forEach { it.onForwardEventClick(eventId) }
|
||||
}
|
||||
|
||||
override fun onReportContentClick(eventId: EventId, senderId: UserId) {
|
||||
callbacks.forEach { it.onReportMessage(eventId, senderId) }
|
||||
}
|
||||
|
||||
override fun onEditPollClick(eventId: EventId) {
|
||||
callbacks.forEach { it.onEditPollClick(eventId) }
|
||||
}
|
||||
|
||||
override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
|
||||
callbacks.forEach { it.onPreviewAttachments(attachments) }
|
||||
}
|
||||
|
||||
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) = Unit
|
||||
|
||||
private fun onSendLocationClick() {
|
||||
callbacks.forEach { it.onSendLocationClick() }
|
||||
}
|
||||
|
||||
private fun onCreatePollClick() {
|
||||
callbacks.forEach { it.onCreatePollClick() }
|
||||
}
|
||||
|
||||
private fun onJoinCallClick() {
|
||||
callbacks.forEach { it.onJoinCallClick(room.roomId) }
|
||||
}
|
||||
|
||||
private fun displaySameRoomToast() {
|
||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||
}
|
||||
|
||||
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
val state = presenter.present()
|
||||
OnLifecycleEvent { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
MessagesView(
|
||||
state = state,
|
||||
onBackClick = this::navigateUp,
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { isLive, event ->
|
||||
if (isLive) {
|
||||
onEventClick(timelineController.mainTimelineMode(), event)
|
||||
} else {
|
||||
val detachedTimelineMode = timelineController.detachedTimelineMode()
|
||||
if (detachedTimelineMode != null) {
|
||||
onEventClick(detachedTimelineMode, event)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUserDataClick = this::onUserDataClick,
|
||||
onLinkClick = { url, customTab ->
|
||||
onLinkClick(
|
||||
activity,
|
||||
isDark,
|
||||
url,
|
||||
state.timelineState.eventSink,
|
||||
customTab
|
||||
)
|
||||
},
|
||||
onSendLocationClick = this::onSendLocationClick,
|
||||
onCreatePollClick = this::onCreatePollClick,
|
||||
onJoinCallClick = this::onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
)
|
||||
|
||||
var focusedEventId by rememberSaveable {
|
||||
mutableStateOf(inputs.focusedEventId)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusedEventId?.also { eventId ->
|
||||
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
// Reset the focused event id to null to avoid refocusing when restoring node.
|
||||
focusedEventId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Timeline>>(Optional.empty())
|
||||
private val liveTimelineFlow = flowOf(liveTimeline)
|
||||
private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||
|
|
@ -55,7 +57,13 @@ class TimelineController @Inject constructor(
|
|||
}
|
||||
|
||||
fun isLive(): Flow<Boolean> {
|
||||
return detachedTimeline.map { !it.isPresent }
|
||||
return detachedTimelineFlow.map { !it.isPresent }
|
||||
}
|
||||
|
||||
fun mainTimelineMode(): Timeline.Mode = liveTimeline.mode
|
||||
|
||||
fun detachedTimelineMode(): Timeline.Mode? {
|
||||
return detachedTimelineFlow.value.orElse(null)?.mode
|
||||
}
|
||||
|
||||
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Unit)) {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<ResolveVerifiedUserSendFailureState>,
|
||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<TimelineState> {
|
||||
@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<EventId?>(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) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TimelineItem>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<TimelineItem> = 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<TimelineItem.Event>().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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<VoiceMessageComposerState> {
|
||||
) : VoiceMessageComposerPresenter {
|
||||
@ContributesBinding(RoomScope::class)
|
||||
@AssistedFactory
|
||||
interface Factory : VoiceMessageComposerPresenter.Factory {
|
||||
override fun create(timelineMode: Timeline.Mode): DefaultVoiceMessageComposerPresenter
|
||||
}
|
||||
|
||||
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
private val mediaSender = mediaSenderFactory.create(timelineMode)
|
||||
|
||||
@Composable
|
||||
override fun present(): VoiceMessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -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<Attachment>) -> Unit = { _ -> lambdaError() },
|
||||
private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() }
|
||||
private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
) : MessagesNavigator {
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
||||
|
|
@ -46,4 +48,8 @@ class FakeMessagesNavigator(
|
|||
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) {
|
||||
onNavigateToRoomLambda(roomId, serverNames)
|
||||
}
|
||||
|
||||
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
onOpenThreadLambda(threadRootId, focusedEventId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReadReceiptData>().toImmutableList()),
|
||||
localSendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
isThreaded = isThreaded,
|
||||
threadInfo = threadInfo,
|
||||
origin = null,
|
||||
timelineItemDebugInfoProvider = debugInfoProvider,
|
||||
messageShieldProvider = messageShieldProvider,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Unit>
|
||||
suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Unit>
|
||||
suspend fun execute(
|
||||
timeline: Timeline,
|
||||
pollStartId: EventId,
|
||||
answerId: String
|
||||
): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Unit> {
|
||||
return room.liveTimeline.endPoll(
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
|
||||
return timeline.endPoll(
|
||||
pollStartId = pollStartId,
|
||||
text = "The poll with event id: $pollStartId has ended."
|
||||
).onSuccess {
|
||||
|
|
|
|||
|
|
@ -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<Unit> {
|
||||
return room.liveTimeline.sendPollResponse(
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
|
||||
return timeline.sendPollResponse(
|
||||
pollStartId = pollStartId,
|
||||
answers = listOf(answerId),
|
||||
).onSuccess {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<CreatePollState> {
|
||||
@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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PollContent> = runCatchingExceptions {
|
||||
timelineProvider
|
||||
getTimelineProvider()
|
||||
.getOrThrow()
|
||||
.getActiveTimeline()
|
||||
.timelineItems
|
||||
.first()
|
||||
|
|
@ -42,30 +58,51 @@ class PollRepository @Inject constructor(
|
|||
pollKind: PollKind,
|
||||
maxSelections: Int,
|
||||
): Result<Unit> = 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<Unit> =
|
||||
timelineProvider
|
||||
.getActiveTimeline()
|
||||
.redactEvent(
|
||||
eventOrTransactionId = pollStartId.toEventOrTransactionId(),
|
||||
reason = null,
|
||||
)
|
||||
getTimelineProvider().flatMap { timelineProvider ->
|
||||
timelineProvider.getActiveTimeline()
|
||||
.redactEvent(
|
||||
eventOrTransactionId = pollStartId.toEventOrTransactionId(),
|
||||
reason = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getTimelineProvider(): Result<TimelineProvider> {
|
||||
return when (timelineMode) {
|
||||
is Timeline.Mode.Thread -> {
|
||||
val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
|
||||
threadedTimelineResult.map { threadedTimeline ->
|
||||
object : TimelineProvider {
|
||||
private val flow = MutableStateFlow<Timeline?>(threadedTimeline)
|
||||
override fun activeTimelineFlow(): StateFlow<Timeline?> = flow
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Result.success(defaultTimelineProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Unit> {
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
|
||||
executionCount++
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Unit> {
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
|
||||
executionCount++
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,5 @@ value class EventId(val value: String) : Serializable {
|
|||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
fun EventId.toThreadId(): ThreadId = ThreadId(value)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Unit>
|
||||
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
|
||||
suspend fun paginate(direction: PaginationDirection): Result<Boolean>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.timeline.item
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
|
||||
data class EventThreadInfo(
|
||||
val threadRootId: ThreadId?,
|
||||
val threadSummary: ThreadSummary?,
|
||||
)
|
||||
|
||||
data class ThreadSummary(
|
||||
val latestEvent: AsyncData<EmbeddedEventInfo>,
|
||||
val numberOfReplies: Long,
|
||||
)
|
||||
|
||||
data class EmbeddedEventInfo(
|
||||
val eventOrTransactionId: EventOrTransactionId,
|
||||
val content: EventContent,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>(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<Timeline> = 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class LastForwardIndicatorsPostProcessor(
|
|||
items: List<MatrixTimelineItem>,
|
||||
): List<MatrixTimelineItem> {
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
|
|||
items: List<MatrixTimelineItem>,
|
||||
hasMoreToLoadBackward: Boolean,
|
||||
hasMoreToLoadForward: Boolean,
|
||||
timelineMode: Timeline.Mode,
|
||||
): List<MatrixTimelineItem> {
|
||||
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
||||
val shouldAddForwardLoadingIndicator = timelineMode is Timeline.Mode.Live && hasMoreToLoadForward && items.isNotEmpty()
|
||||
val currentTimestamp = systemClock.epochMillis()
|
||||
return buildList {
|
||||
if (hasMoreToLoadBackward) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
|
|||
): List<MatrixTimelineItem> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
return if (mode == Timeline.Mode.LIVE) {
|
||||
return if (mode is Timeline.Mode.Live) {
|
||||
buildList {
|
||||
addAll(items)
|
||||
add(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue