Name a sender the timeline has no profile for from the room member list

The timeline renders a message with the sender's raw Matrix ID whenever the
SDK has no profile for them, even though the room member list the same screen
already holds usually knows their display name and avatar.

The timeline item factory now falls back to the room member for a sender whose
profile is not resolved, both when the item is first built and when the member
list arrives afterwards, so the name and the avatar settle on the display name
instead of staying on the ID.
This commit is contained in:
hayaksi1
2026-08-25 15:22:32 +03:00
parent 23437e4427
commit e09d6e0a22
2 changed files with 95 additions and 1 deletions
@@ -28,10 +28,12 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
@@ -64,7 +66,7 @@ class TimelineItemEventFactory(
val currentSender = currentTimelineItem.event.sender val currentSender = currentTimelineItem.event.sender
val groupPosition = val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index) computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderProfile = currentTimelineItem.event.senderProfile val senderProfile = currentTimelineItem.event.senderProfile.orFallbackTo(roomMembers, currentSender)
val sentTime = dateFormatter.format( val sentTime = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp, timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly, mode = DateFormatterMode.TimeOnly,
@@ -138,11 +140,32 @@ class TimelineItemEventFactory(
roomMembers: List<RoomMember>, roomMembers: List<RoomMember>,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
): TimelineItem.Event { ): TimelineItem.Event {
val senderProfile = receivedMatrixTimelineItem.event.senderProfile.orFallbackTo(roomMembers, timelineItem.senderId)
return timelineItem.copy( return timelineItem.copy(
senderProfile = senderProfile,
senderAvatar = timelineItem.senderAvatar.copy(
name = senderProfile.getDisambiguatedDisplayName(timelineItem.senderId),
url = senderProfile.getAvatarUrl(),
),
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts) readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts)
) )
} }
/**
* The timeline only knows the profiles the SDK has resolved, so a sender it has nothing for is rendered as a raw
* user ID. The room member list usually knows that user, so use it rather than showing the ID.
*/
private fun ProfileDetails.orFallbackTo(roomMembers: List<RoomMember>, senderId: UserId): ProfileDetails {
if (this is ProfileDetails.Ready) return this
val member = roomMembers.find { it.userId == senderId } ?: return this
return ProfileDetails.Ready(
displayName = member.displayName,
displayNameAmbiguous = member.isNameAmbiguous,
avatarUrl = member.avatarUrl,
displayedStatus = member.displayedStatus,
)
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
if (!config.computeReactions) { if (!config.computeReactions) {
return TimelineItemReactions(reactions = persistentListOf()) return TimelineItemReactions(reactions = persistentListOf())
@@ -16,7 +16,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.libraries.matrix.api.core.UniqueId 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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -128,6 +131,74 @@ class TimelineItemsFactoryTest {
} }
} }
@Test
fun `a sender the timeline knows nothing about is named from the room member list`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
content = aMessageContent(body = "A regular message"),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = listOf(aRoomMember(userId = A_USER_ID, displayName = "Alice")),
renderReadReceipts = false,
)
val event = awaitItem().filterIsInstance<TimelineItem.Event>().single()
assertThat(event.senderProfile).isEqualTo(
ProfileDetails.Ready(
displayName = "Alice",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
)
)
assertThat(event.senderAvatar.name).isEqualTo("Alice")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a sender the room member list knows nothing about keeps the profile the timeline gave`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
content = aMessageContent(body = "A regular message"),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = listOf(aRoomMember(userId = A_USER_ID_2, displayName = "Bob")),
renderReadReceipts = false,
)
val event = awaitItem().filterIsInstance<TimelineItem.Event>().single()
assertThat(event.senderProfile).isEqualTo(ProfileDetails.Unavailable)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> { private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> {
val factory = aTimelineItemsFactory( val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig( config = TimelineItemsFactoryConfig(