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,
endTime = endTime,
isEdited = false,
threadInfo = null,
)
private fun aPollContentState(

View File

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

View File

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

View File

@@ -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<String, ImmutableList<UserId>>,
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 {

View File

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

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

View File

@@ -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<String, ImmutableList<UserId>> = 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,
)

View File

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

View File

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

View File

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