Threads - first iteration (#5165)

* Initial threads support: parse `ThreadSummary`.

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

* Add `Threaded` timeline mode

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

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

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

* Send attachments and location in threads

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

* Display thread summaries only when the feature flag is enabled

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

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

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-08-19 15:35:48 +02:00 committed by GitHub
parent cc10ba41fd
commit 35928e3630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 1520 additions and 339 deletions

View file

@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.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(

View file

@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.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(

View file

@ -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,
)
}

View file

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

View file

@ -8,10 +8,12 @@
package io.element.android.libraries.matrix.api.room
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
}

View file

@ -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>

View file

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

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.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

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.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()

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.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(

View file

@ -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) {

View file

@ -11,6 +11,8 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.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
}

View file

@ -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 {

View file

@ -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 ->

View file

@ -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
)
}

View file

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

View file

@ -7,7 +7,12 @@
package io.element.android.libraries.matrix.impl.timeline.item.event
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

View file

@ -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 {

View file

@ -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) {

View file

@ -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)

View file

@ -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(

View file

@ -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(),
)
}

View file

@ -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 = {})

View file

@ -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,

View file

@ -12,19 +12,20 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.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

View file

@ -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(

View file

@ -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))
}

View file

@ -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,

View file

@ -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
)

View file

@ -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,
)

View file

@ -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,

View file

@ -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.
*/

View file

@ -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,
)
}

View file

@ -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(

View file

@ -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
}
}
}

View file

@ -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),

View file

@ -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,
)
}

View file

@ -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 {