Add thread decoration with latest event details (#5355)

* Add thread decoration with latest event details
* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-09-23 16:57:50 +02:00 committed by GitHub
parent 5cadd37fa6
commit 0a5c178fe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 554 additions and 282 deletions

View file

@ -34,6 +34,7 @@ enum class AvatarSize(val dp: Dp) {
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
TimelineThreadLatestEventSender(24.dp),
ComposerAlert(32.dp),

View file

@ -7,8 +7,19 @@
package io.element.android.libraries.eventformatter.api
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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
interface TimelineEventFormatter {
fun format(event: EventTimelineItem): CharSequence?
fun format(event: EventTimelineItem): CharSequence? {
return format(
content = event.content,
isOutgoing = event.isOwn,
sender = event.sender,
senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
)
}
fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence?
}

View file

@ -13,7 +13,9 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.core.UserId
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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -43,12 +45,16 @@ class DefaultTimelineEventFormatter(
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
return format(event.content, isOutgoing, event.sender, senderDisambiguatedDisplayName)
}
override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? {
return when (content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
profileChangeContentFormatter.format(content, sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
@ -66,7 +72,7 @@ class DefaultTimelineEventFormatter(
is FailedToParseStateContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event: $event")
error("You should not use this formatter for this event content: $content")
}
sp.getString(CommonStrings.common_unsupported_event)
}

View file

@ -14,7 +14,6 @@ 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
@ -175,7 +174,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
) {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type)
return MessageContent(body, null, false, null, type)
}
val sharedContentMessagesTypes = arrayOf(

View file

@ -14,7 +14,6 @@ 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
@ -130,7 +129,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
fun `Message contents`() {
val body = "Shared body"
fun createMessageContent(type: MessageType): MessageContent {
return MessageContent(body, null, false, EventThreadInfo(null, null), type)
return MessageContent(body, null, false, null, type)
}
val sharedContentMessagesTypes = arrayOf(

View file

@ -14,10 +14,10 @@ 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?,
)
sealed interface EventThreadInfo {
data class ThreadRoot(val summary: ThreadSummary) : EventThreadInfo
data class ThreadResponse(val threadRootId: ThreadId) : EventThreadInfo
}
data class ThreadSummary(
val latestEvent: AsyncData<EmbeddedEventInfo>,

View file

@ -24,7 +24,7 @@ data class MessageContent(
val body: String,
val inReplyTo: InReplyTo?,
val isEdited: Boolean,
val threadInfo: EventThreadInfo,
val threadInfo: EventThreadInfo?,
val type: MessageType
) : EventContent

View file

@ -38,7 +38,7 @@ 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?, threadInfo: EventThreadInfo): 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(

View file

@ -79,7 +79,7 @@ class TimelineEventContentMapper(
content = map(latestEvent.content),
senderId = UserId(latestEvent.sender),
senderProfile = latestEvent.senderProfile.map(),
timestamp = latestEvent.timestamp.toLong()
timestamp = latestEvent.timestamp.toLong(),
)
)
}
@ -89,10 +89,12 @@ class TimelineEventContentMapper(
numberOfReplies = numberOfReplies,
)
}
val threadInfo = EventThreadInfo(
threadRootId = it.content.threadRoot?.let(::ThreadId),
threadSummary = threadSummary,
)
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 -> {

View file

@ -104,7 +104,7 @@ fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo: EventThreadInfo? = null,
messageType: MessageType = TextMessageType(
body = body,
formatted = null

View file

@ -134,10 +134,7 @@ class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
private fun aMessageContent(
body: String,
type: MessageType,
threadInfo: EventThreadInfo = EventThreadInfo(
threadRootId = null,
threadSummary = null,
),
threadInfo: EventThreadInfo? = null,
) = MessageContent(
body = body,
inReplyTo = null,

View file

@ -8,7 +8,6 @@
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
@ -70,7 +69,7 @@ class InReplyToDetailTest {
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = FormattedBody(
@ -95,7 +94,7 @@ class InReplyToDetailTest {
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = null,

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.textcomposer.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -49,7 +50,7 @@ sealed interface MessageComposerMode {
get() = this is Reply &&
replyToDetails is InReplyToDetails.Ready &&
replyToDetails.eventContent is MessageContent &&
(replyToDetails.eventContent as MessageContent).threadInfo.threadRootId != null
(replyToDetails.eventContent as MessageContent).threadInfo is EventThreadInfo.ThreadResponse
}
fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean {