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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class LoadingIndicatorsPostProcessorTest {
|
|||
items = listOf(messageEvent, messageEvent2),
|
||||
hasMoreToLoadBackward = true,
|
||||
hasMoreToLoadForward = false,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MatrixTimelineItem.Virtual(
|
||||
|
|
@ -46,6 +47,7 @@ class LoadingIndicatorsPostProcessorTest {
|
|||
items = listOf(messageEvent, messageEvent2),
|
||||
hasMoreToLoadBackward = false,
|
||||
hasMoreToLoadForward = true,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
messageEvent,
|
||||
|
|
@ -68,6 +70,7 @@ class LoadingIndicatorsPostProcessorTest {
|
|||
items = listOf(messageEvent, messageEvent2),
|
||||
hasMoreToLoadBackward = true,
|
||||
hasMoreToLoadForward = true,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MatrixTimelineItem.Virtual(
|
||||
|
|
@ -97,6 +100,7 @@ class LoadingIndicatorsPostProcessorTest {
|
|||
items = listOf(),
|
||||
hasMoreToLoadBackward = true,
|
||||
hasMoreToLoadForward = true,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MatrixTimelineItem.Virtual(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import org.junit.Test
|
|||
class RoomBeginningPostProcessorTest {
|
||||
@Test
|
||||
fun `processor returns empty list when empty list is provided`() {
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(
|
||||
items = emptyList(),
|
||||
isDm = true,
|
||||
|
|
@ -27,7 +27,7 @@ class RoomBeginningPostProcessorTest {
|
|||
|
||||
@Test
|
||||
fun `processor returns the provided list when it only contains a message`() {
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(
|
||||
items = listOf(messageEvent),
|
||||
isDm = true,
|
||||
|
|
@ -39,7 +39,7 @@ class RoomBeginningPostProcessorTest {
|
|||
|
||||
@Test
|
||||
fun `processor returns the provided list when it only contains a message and the roomCreator is not provided`() {
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(
|
||||
items = listOf(messageEvent),
|
||||
isDm = true,
|
||||
|
|
@ -56,7 +56,7 @@ class RoomBeginningPostProcessorTest {
|
|||
roomCreateEvent,
|
||||
roomCreatorJoinEvent,
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(
|
||||
items = timelineItems,
|
||||
isDm = true,
|
||||
|
|
@ -72,7 +72,7 @@ class RoomBeginningPostProcessorTest {
|
|||
roomCreateEvent,
|
||||
roomCreatorJoinEvent,
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.PINNED_EVENTS)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.PinnedEvents)
|
||||
val processedItems = processor.process(
|
||||
items = timelineItems,
|
||||
isDm = true,
|
||||
|
|
@ -94,7 +94,7 @@ class RoomBeginningPostProcessorTest {
|
|||
otherMemberJoinEvent,
|
||||
messageEvent,
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ class RoomBeginningPostProcessorTest {
|
|||
roomCreateEvent,
|
||||
roomCreatorJoinEvent,
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEmpty()
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ class RoomBeginningPostProcessorTest {
|
|||
val timelineItems = listOf(
|
||||
roomCreatorJoinEvent,
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEmpty()
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ class RoomBeginningPostProcessorTest {
|
|||
roomCreateEvent,
|
||||
otherMemberJoinEvent,
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
|
||||
val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
|
||||
assertThat(processedItems).isEqualTo(listOf(otherMemberJoinEvent))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class FakeTimeline(
|
|||
),
|
||||
override val membershipChangeEventReceived: Flow<Unit> = MutableSharedFlow(),
|
||||
private val cancelSendResult: (TransactionId) -> Result<Unit> = { lambdaError() },
|
||||
override val mode: Timeline.Mode = Timeline.Mode.Live,
|
||||
) : Timeline {
|
||||
var sendMessageLambda: (
|
||||
body: String,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
|
|||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
|
|
@ -103,7 +104,7 @@ fun aMessageContent(
|
|||
body: String = "body",
|
||||
inReplyTo: InReplyTo? = null,
|
||||
isEdited: Boolean = false,
|
||||
isThreaded: Boolean = false,
|
||||
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
|
||||
messageType: MessageType = TextMessageType(
|
||||
body = body,
|
||||
formatted = null
|
||||
|
|
@ -112,7 +113,7 @@ fun aMessageContent(
|
|||
body = body,
|
||||
inReplyTo = inReplyTo,
|
||||
isEdited = isEdited,
|
||||
isThreaded = isThreaded,
|
||||
threadInfo = threadInfo,
|
||||
type = messageType
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
|
|
@ -133,11 +134,15 @@ class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
|
|||
private fun aMessageContent(
|
||||
body: String,
|
||||
type: MessageType,
|
||||
threadInfo: EventThreadInfo = EventThreadInfo(
|
||||
threadRootId = null,
|
||||
threadSummary = null,
|
||||
),
|
||||
) = MessageContent(
|
||||
body = body,
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
isThreaded = false,
|
||||
threadInfo = threadInfo,
|
||||
type = type,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.libraries.matrix.ui.messages.reply
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
|
|
@ -69,7 +70,7 @@ class InReplyToDetailTest {
|
|||
body = "**Hello!**",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
isThreaded = false,
|
||||
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
|
||||
type = TextMessageType(
|
||||
body = "**Hello!**",
|
||||
formatted = FormattedBody(
|
||||
|
|
@ -94,7 +95,7 @@ class InReplyToDetailTest {
|
|||
body = "**Hello!**",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
isThreaded = false,
|
||||
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
|
||||
type = TextMessageType(
|
||||
body = "**Hello!**",
|
||||
formatted = null,
|
||||
|
|
|
|||
|
|
@ -8,21 +8,33 @@
|
|||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.flatMapCatching
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
class MediaSender @Inject constructor(
|
||||
class MediaSender @AssistedInject constructor(
|
||||
private val preProcessor: MediaPreProcessor,
|
||||
private val room: JoinedRoom,
|
||||
@Assisted private val timelineMode: Timeline.Mode,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
timelineMode: Timeline.Mode,
|
||||
): MediaSender
|
||||
}
|
||||
|
||||
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
|
||||
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
|
||||
|
||||
|
|
@ -46,12 +58,14 @@ class MediaSender @Inject constructor(
|
|||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<Unit> {
|
||||
return room.liveTimeline.sendMedia(
|
||||
uploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
return getTimeline().flatMap {
|
||||
it.sendMedia(
|
||||
uploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
.handleSendResult()
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +85,7 @@ class MediaSender @Inject constructor(
|
|||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
room.liveTimeline.sendMedia(
|
||||
getTimeline().getOrThrow().sendMedia(
|
||||
uploadInfo = info,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
|
|
@ -101,7 +115,7 @@ class MediaSender @Inject constructor(
|
|||
audioInfo = audioInfo,
|
||||
waveform = waveForm,
|
||||
)
|
||||
room.liveTimeline.sendMedia(
|
||||
getTimeline().getOrThrow().sendMedia(
|
||||
uploadInfo = newInfo,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
|
|
@ -186,6 +200,15 @@ class MediaSender @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun getTimeline(): Result<Timeline> {
|
||||
return when (timelineMode) {
|
||||
is Timeline.Mode.Thread -> {
|
||||
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId))
|
||||
}
|
||||
else -> Result.success(room.liveTimeline)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any temporary files or resources used during the media processing.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
|
|
@ -160,6 +161,7 @@ class MediaSenderTest {
|
|||
) = MediaSender(
|
||||
preProcessor = preProcessor,
|
||||
room = room,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,9 +97,9 @@ class MediaGalleryRootNode @AssistedInject constructor(
|
|||
val mode = when (item) {
|
||||
is MediaItem.Audio,
|
||||
is MediaItem.Voice,
|
||||
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.MEDIA)
|
||||
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media)
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.MEDIA)
|
||||
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
|
||||
}
|
||||
overlay.show(
|
||||
NavTarget.MediaViewer(
|
||||
|
|
|
|||
|
|
@ -82,8 +82,9 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
}
|
||||
when (timelineMode) {
|
||||
null -> timelineMediaGalleryDataSource
|
||||
Timeline.Mode.LIVE,
|
||||
Timeline.Mode.FOCUSED_ON_EVENT -> {
|
||||
Timeline.Mode.Live,
|
||||
is Timeline.Mode.FocusedOnEvent,
|
||||
is Timeline.Mode.Thread -> {
|
||||
// Does timelineMediaGalleryDataSource knows the eventId?
|
||||
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
|
||||
val isEventKnown = lastData?.hasEvent(eventId) == true
|
||||
|
|
@ -97,14 +98,14 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
Timeline.Mode.PINNED_EVENTS -> {
|
||||
Timeline.Mode.PinnedEvents -> {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
onlyPinnedEvents = true,
|
||||
)
|
||||
}
|
||||
Timeline.Mode.MEDIA -> timelineMediaGalleryDataSource
|
||||
Timeline.Mode.Media -> timelineMediaGalleryDataSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ class MediaViewerDataSourceTest {
|
|||
fun `test dataFlow with data galleryMode image`() = runTest {
|
||||
val galleryDataSource = FakeMediaGalleryDataSource()
|
||||
val sut = createMediaViewerDataSource(
|
||||
mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media),
|
||||
galleryDataSource = galleryDataSource,
|
||||
)
|
||||
sut.dataFlow().test {
|
||||
|
|
@ -159,7 +159,7 @@ class MediaViewerDataSourceTest {
|
|||
fun `test dataFlow with data galleryMode files`() = runTest {
|
||||
val galleryDataSource = FakeMediaGalleryDataSource()
|
||||
val sut = createMediaViewerDataSource(
|
||||
mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
|
||||
galleryDataSource = galleryDataSource,
|
||||
)
|
||||
sut.dataFlow().test {
|
||||
|
|
@ -265,7 +265,7 @@ class MediaViewerDataSourceTest {
|
|||
}
|
||||
|
||||
private fun TestScope.createMediaViewerDataSource(
|
||||
mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media),
|
||||
galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(),
|
||||
mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
|
||||
localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ class MediaViewerPresenterTest {
|
|||
@Test
|
||||
fun `present - snackbar displayed when there is no more items forward images and videos`() {
|
||||
`present - snackbar displayed when there is no more items forward`(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media),
|
||||
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
|
||||
)
|
||||
}
|
||||
|
|
@ -536,7 +536,7 @@ class MediaViewerPresenterTest {
|
|||
@Test
|
||||
fun `present - snackbar displayed when there is no more items forward files and audio`() {
|
||||
`present - snackbar displayed when there is no more items forward`(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
|
||||
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
|
||||
)
|
||||
}
|
||||
|
|
@ -599,7 +599,7 @@ class MediaViewerPresenterTest {
|
|||
@Test
|
||||
fun `present - snackbar displayed when there is no more items backward images and videos`() {
|
||||
`present - snackbar displayed when there is no more items backward`(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media),
|
||||
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
|
||||
)
|
||||
}
|
||||
|
|
@ -607,7 +607,7 @@ class MediaViewerPresenterTest {
|
|||
@Test
|
||||
fun `present - snackbar displayed when there is no more items backward files and audio`() {
|
||||
`present - snackbar displayed when there is no more items backward`(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
|
||||
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ sealed interface MessageComposerMode {
|
|||
get() = this is Reply &&
|
||||
replyToDetails is InReplyToDetails.Ready &&
|
||||
replyToDetails.eventContent is MessageContent &&
|
||||
(replyToDetails.eventContent as MessageContent).isThreaded
|
||||
(replyToDetails.eventContent as MessageContent).threadInfo.threadRootId != null
|
||||
}
|
||||
|
||||
fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue