From 0b5c4fc8bb04b4f109bd1919b465d927730f4618 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 19 Dec 2025 10:43:40 +0100 Subject: [PATCH] Add `threadInfo` field to message like timeline events (#5930) * Add `threadInfo` field to message like timeline events: - Polls - Stickers - UTDs * Add missing cases for `EventTimeline.threadInfo()` --- .../PollContentStateFactoryTest.kt | 1 + ...efaultPinnedMessagesBannerFormatterTest.kt | 6 +- .../DefaultRoomLatestEventFormatterTest.kt | 6 +- .../api/timeline/item/event/EventContent.kt | 7 +- .../timeline/item/event/EventTimelineItem.kt | 8 +- .../item/event/TimelineEventContentMapper.kt | 79 +++++++++++-------- .../matrix/test/timeline/TimelineFixture.kt | 4 + .../reply/InReplyToDetailsProvider.kt | 3 +- .../messages/reply/InReplyToMetadataKtTest.kt | 11 ++- .../datasource/DefaultEventItemFactoryTest.kt | 6 +- 10 files changed, 87 insertions(+), 44 deletions(-) diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt index 438d451199..277508681e 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt @@ -220,6 +220,7 @@ class PollContentStateFactoryTest { votes = votes, endTime = endTime, isEdited = false, + threadInfo = null, ) private fun aPollContentState( diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index 10c09ad41d..b58bbb4b25 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -103,7 +103,11 @@ class DefaultPinnedMessagesBannerFormatterTest { fun `Unable to decrypt content`() { val expected = "Waiting for this message" val senderName = "Someone" - val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val message = createRoomEvent( + sentByYou = false, + senderDisplayName = senderName, + content = UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null) + ) val result = formatter.format(message) assertThat(result).isEqualTo(expected) } diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index c06b79ead9..0da3134098 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -112,7 +112,11 @@ class DefaultRoomLatestEventFormatterTest { val expected = "Waiting for this message" val senderName = "Someone" sequenceOf(false, true).forEach { isDm -> - val message = createLatestEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val message = createLatestEvent( + sentByYou = false, + senderDisplayName = senderName, + content = UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), + ) val result = formatter.format(message, isDm) if (isDm) { assertThat(result).isEqualTo(expected) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index c6272e8f52..b6ed7dc602 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -26,7 +26,7 @@ data class MessageContent( val inReplyTo: InReplyTo?, val isEdited: Boolean, val threadInfo: EventThreadInfo?, - val type: MessageType + val type: MessageType, ) : EventContent data object RedactedContent : EventContent @@ -36,6 +36,7 @@ data class StickerContent( val body: String?, val info: ImageInfo, val source: MediaSource, + val threadInfo: EventThreadInfo?, ) : EventContent { val bestDescription: String get() = body ?: filename @@ -49,10 +50,12 @@ data class PollContent( val votes: ImmutableMap>, val endTime: ULong?, val isEdited: Boolean, + val threadInfo: EventThreadInfo?, ) : EventContent data class UnableToDecryptContent( - val data: Data + val data: Data, + val threadInfo: EventThreadInfo?, ) : EventContent { @Immutable sealed interface Data { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 8294b78c24..401240f927 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -39,7 +39,13 @@ data class EventTimelineItem( return (content as? MessageContent)?.inReplyTo } - fun threadInfo(): EventThreadInfo? = (content as? MessageContent)?.threadInfo + fun threadInfo(): EventThreadInfo? = when (content) { + is MessageContent -> content.threadInfo + is PollContent -> content.threadInfo + is StickerContent -> content.threadInfo + is UnableToDecryptContent -> content.threadInfo + else -> null + } fun hasNotLoadedInReplyTo(): Boolean { val details = inReplyTo() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index d0545d3f0a..b1758fb734 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.impl.room.join.map import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import org.matrix.rustcomponents.sdk.EmbeddedEventDetails +import org.matrix.rustcomponents.sdk.MsgLikeContent import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.use @@ -68,37 +69,11 @@ class TimelineEventContentMapper( when (val kind = it.content.kind) { is MsgLikeKind.Message -> { val inReplyTo = it.content.inReplyTo - 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 threadRootId = it.content.threadRoot?.let(::ThreadId) - val threadInfo = when { - threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary) - threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId) - else -> null - } - eventMessageMapper.map(kind, inReplyTo, threadInfo) + eventMessageMapper.map( + message = kind, + inReplyTo = inReplyTo, + threadInfo = extractThreadInfo(it.content) + ) } is MsgLikeKind.Redacted -> { RedactedContent @@ -114,11 +89,13 @@ class TimelineEventContentMapper( }.toImmutableMap(), endTime = kind.endTime, isEdited = kind.hasBeenEdited, + threadInfo = extractThreadInfo(it.content), ) } is MsgLikeKind.UnableToDecrypt -> { UnableToDecryptContent( - data = kind.msg.map() + data = kind.msg.map(), + threadInfo = extractThreadInfo(it.content), ) } is MsgLikeKind.Sticker -> { @@ -127,6 +104,7 @@ class TimelineEventContentMapper( body = null, info = kind.info.map(), source = kind.source.map(), + threadInfo = extractThreadInfo(it.content), ) } is MsgLikeKind.Other -> UnknownContent @@ -159,6 +137,43 @@ class TimelineEventContentMapper( } } } + + private fun extractThreadInfo(content: MsgLikeContent): EventThreadInfo? { + val threadSummary = extractThreadSummary(content.threadSummary) + val threadRootId = content.threadRoot?.let(::ThreadId) + return when { + threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary) + threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId) + else -> null + } + } + + private fun extractThreadSummary(threadSummary: org.matrix.rustcomponents.sdk.ThreadSummary?): ThreadSummary? { + return 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, + ) + } + } } private fun RustMembershipChange.map(): MembershipChange { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index c8d8ff6015..24c26a6734 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -123,11 +123,13 @@ fun aStickerContent( info: ImageInfo, mediaSource: MediaSource, body: String? = null, + threadInfo: EventThreadInfo? = null, ) = StickerContent( filename = filename, body = body, info = info, source = mediaSource, + threadInfo = threadInfo, ) fun aTimelineItemDebugInfo( @@ -148,6 +150,7 @@ fun aPollContent( votes: ImmutableMap> = persistentMapOf(), endTime: ULong? = null, isEdited: Boolean = false, + threadInfo: EventThreadInfo? = null, ) = PollContent( question = question, kind = kind, @@ -156,4 +159,5 @@ fun aPollContent( votes = votes, endTime = endTime, isEdited = isEdited, + threadInfo = threadInfo, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index ac545ed8ea..0727b0b7ec 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -89,6 +89,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider votes = persistentMapOf(), endTime = null, isEdited = false, + threadInfo = null, ), ).map { aInReplyToDetails( @@ -116,7 +117,7 @@ class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() { override val values: Sequence get() = sequenceOf( RedactedContent, - UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), ).map { aInReplyToDetails( eventContent = it, diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index e7cae1151b..004e622211 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -134,7 +134,8 @@ class InReplyToMetadataKtTest { filename = "filename", body = "body", info = anImageInfo(), - source = aMediaSource(url = "url") + source = aMediaSource(url = "url"), + threadInfo = null, ) ).metadata(hideImage = false) }.test { @@ -161,7 +162,8 @@ class InReplyToMetadataKtTest { filename = "filename", body = "body", info = anImageInfo(), - source = aMediaSource(url = "url") + source = aMediaSource(url = "url"), + threadInfo = null, ) ).metadata(hideImage = true) }.test { @@ -445,7 +447,10 @@ class InReplyToMetadataKtTest { fun `unable to decrypt content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( - eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown) + eventContent = UnableToDecryptContent( + data = UnableToDecryptContent.Data.Unknown, + threadInfo = null, + ), ).metadata(hideImage = false) }.test { awaitItem().let { diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt index 68d6564d12..c70d658418 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -84,7 +84,7 @@ class DefaultEventItemFactoryTest { ), mediaSource = MediaSource("") ), - UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), UnknownContent, ) contents.forEach { @@ -397,8 +397,8 @@ class DefaultEventItemFactoryTest { height = 1L, width = 2L, blurhash = null, - ) - ) + ), + ), ) ) )