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()`
This commit is contained in:
Jorge Martin Espinosa 2025-12-19 10:43:40 +01:00 committed by GitHub
parent 602498a36b
commit 0b5c4fc8bb
10 changed files with 87 additions and 44 deletions

View file

@ -220,6 +220,7 @@ class PollContentStateFactoryTest {
votes = votes, votes = votes,
endTime = endTime, endTime = endTime,
isEdited = false, isEdited = false,
threadInfo = null,
) )
private fun aPollContentState( private fun aPollContentState(

View file

@ -103,7 +103,11 @@ class DefaultPinnedMessagesBannerFormatterTest {
fun `Unable to decrypt content`() { fun `Unable to decrypt content`() {
val expected = "Waiting for this message" val expected = "Waiting for this message"
val senderName = "Someone" 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) val result = formatter.format(message)
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }

View file

@ -112,7 +112,11 @@ class DefaultRoomLatestEventFormatterTest {
val expected = "Waiting for this message" val expected = "Waiting for this message"
val senderName = "Someone" val senderName = "Someone"
sequenceOf(false, true).forEach { isDm -> 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) val result = formatter.format(message, isDm)
if (isDm) { if (isDm) {
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)

View file

@ -26,7 +26,7 @@ data class MessageContent(
val inReplyTo: InReplyTo?, val inReplyTo: InReplyTo?,
val isEdited: Boolean, val isEdited: Boolean,
val threadInfo: EventThreadInfo?, val threadInfo: EventThreadInfo?,
val type: MessageType val type: MessageType,
) : EventContent ) : EventContent
data object RedactedContent : EventContent data object RedactedContent : EventContent
@ -36,6 +36,7 @@ data class StickerContent(
val body: String?, val body: String?,
val info: ImageInfo, val info: ImageInfo,
val source: MediaSource, val source: MediaSource,
val threadInfo: EventThreadInfo?,
) : EventContent { ) : EventContent {
val bestDescription: String val bestDescription: String
get() = body ?: filename get() = body ?: filename
@ -49,10 +50,12 @@ data class PollContent(
val votes: ImmutableMap<String, ImmutableList<UserId>>, val votes: ImmutableMap<String, ImmutableList<UserId>>,
val endTime: ULong?, val endTime: ULong?,
val isEdited: Boolean, val isEdited: Boolean,
val threadInfo: EventThreadInfo?,
) : EventContent ) : EventContent
data class UnableToDecryptContent( data class UnableToDecryptContent(
val data: Data val data: Data,
val threadInfo: EventThreadInfo?,
) : EventContent { ) : EventContent {
@Immutable @Immutable
sealed interface Data { sealed interface Data {

View file

@ -39,7 +39,13 @@ data class EventTimelineItem(
return (content as? MessageContent)?.inReplyTo 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 { fun hasNotLoadedInReplyTo(): Boolean {
val details = inReplyTo() val details = inReplyTo()

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.impl.room.join.map
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.EmbeddedEventDetails import org.matrix.rustcomponents.sdk.EmbeddedEventDetails
import org.matrix.rustcomponents.sdk.MsgLikeContent
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
@ -68,37 +69,11 @@ class TimelineEventContentMapper(
when (val kind = it.content.kind) { when (val kind = it.content.kind) {
is MsgLikeKind.Message -> { is MsgLikeKind.Message -> {
val inReplyTo = it.content.inReplyTo val inReplyTo = it.content.inReplyTo
val threadSummary = it.content.threadSummary?.use { summary -> eventMessageMapper.map(
val numberOfReplies = summary.numReplies().toLong() message = kind,
val latestEvent = summary.latestEvent() inReplyTo = inReplyTo,
val details = when (latestEvent) { threadInfo = extractThreadInfo(it.content)
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)
} }
is MsgLikeKind.Redacted -> { is MsgLikeKind.Redacted -> {
RedactedContent RedactedContent
@ -114,11 +89,13 @@ class TimelineEventContentMapper(
}.toImmutableMap(), }.toImmutableMap(),
endTime = kind.endTime, endTime = kind.endTime,
isEdited = kind.hasBeenEdited, isEdited = kind.hasBeenEdited,
threadInfo = extractThreadInfo(it.content),
) )
} }
is MsgLikeKind.UnableToDecrypt -> { is MsgLikeKind.UnableToDecrypt -> {
UnableToDecryptContent( UnableToDecryptContent(
data = kind.msg.map() data = kind.msg.map(),
threadInfo = extractThreadInfo(it.content),
) )
} }
is MsgLikeKind.Sticker -> { is MsgLikeKind.Sticker -> {
@ -127,6 +104,7 @@ class TimelineEventContentMapper(
body = null, body = null,
info = kind.info.map(), info = kind.info.map(),
source = kind.source.map(), source = kind.source.map(),
threadInfo = extractThreadInfo(it.content),
) )
} }
is MsgLikeKind.Other -> UnknownContent 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 { private fun RustMembershipChange.map(): MembershipChange {

View file

@ -123,11 +123,13 @@ fun aStickerContent(
info: ImageInfo, info: ImageInfo,
mediaSource: MediaSource, mediaSource: MediaSource,
body: String? = null, body: String? = null,
threadInfo: EventThreadInfo? = null,
) = StickerContent( ) = StickerContent(
filename = filename, filename = filename,
body = body, body = body,
info = info, info = info,
source = mediaSource, source = mediaSource,
threadInfo = threadInfo,
) )
fun aTimelineItemDebugInfo( fun aTimelineItemDebugInfo(
@ -148,6 +150,7 @@ fun aPollContent(
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(), votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null, endTime: ULong? = null,
isEdited: Boolean = false, isEdited: Boolean = false,
threadInfo: EventThreadInfo? = null,
) = PollContent( ) = PollContent(
question = question, question = question,
kind = kind, kind = kind,
@ -156,4 +159,5 @@ fun aPollContent(
votes = votes, votes = votes,
endTime = endTime, endTime = endTime,
isEdited = isEdited, isEdited = isEdited,
threadInfo = threadInfo,
) )

View file

@ -89,6 +89,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
votes = persistentMapOf(), votes = persistentMapOf(),
endTime = null, endTime = null,
isEdited = false, isEdited = false,
threadInfo = null,
), ),
).map { ).map {
aInReplyToDetails( aInReplyToDetails(
@ -116,7 +117,7 @@ class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails> override val values: Sequence<InReplyToDetails>
get() = sequenceOf( get() = sequenceOf(
RedactedContent, RedactedContent,
UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
).map { ).map {
aInReplyToDetails( aInReplyToDetails(
eventContent = it, eventContent = it,

View file

@ -134,7 +134,8 @@ class InReplyToMetadataKtTest {
filename = "filename", filename = "filename",
body = "body", body = "body",
info = anImageInfo(), info = anImageInfo(),
source = aMediaSource(url = "url") source = aMediaSource(url = "url"),
threadInfo = null,
) )
).metadata(hideImage = false) ).metadata(hideImage = false)
}.test { }.test {
@ -161,7 +162,8 @@ class InReplyToMetadataKtTest {
filename = "filename", filename = "filename",
body = "body", body = "body",
info = anImageInfo(), info = anImageInfo(),
source = aMediaSource(url = "url") source = aMediaSource(url = "url"),
threadInfo = null,
) )
).metadata(hideImage = true) ).metadata(hideImage = true)
}.test { }.test {
@ -445,7 +447,10 @@ class InReplyToMetadataKtTest {
fun `unable to decrypt content`() = runTest { fun `unable to decrypt content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady( anInReplyToDetailsReady(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown) eventContent = UnableToDecryptContent(
data = UnableToDecryptContent.Data.Unknown,
threadInfo = null,
),
).metadata(hideImage = false) ).metadata(hideImage = false)
}.test { }.test {
awaitItem().let { awaitItem().let {

View file

@ -84,7 +84,7 @@ class DefaultEventItemFactoryTest {
), ),
mediaSource = MediaSource("") mediaSource = MediaSource("")
), ),
UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
UnknownContent, UnknownContent,
) )
contents.forEach { contents.forEach {
@ -397,8 +397,8 @@ class DefaultEventItemFactoryTest {
height = 1L, height = 1L,
width = 2L, width = 2L,
blurhash = null, blurhash = null,
) ),
) ),
) )
) )
) )