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.AvatarSize
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.room.RoomMember
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.event.ProfileDetails
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.ui.messages.reply.map
@@ -64,7 +66,7 @@ class TimelineItemEventFactory(
val currentSender = currentTimelineItem.event.sender
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderProfile = currentTimelineItem.event.senderProfile
val senderProfile = currentTimelineItem.event.senderProfile.orFallbackTo(roomMembers, currentSender)
val sentTime = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly,
@@ -138,11 +140,32 @@ class TimelineItemEventFactory(
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
): TimelineItem.Event {
val senderProfile = receivedMatrixTimelineItem.event.senderProfile.orFallbackTo(roomMembers, timelineItem.senderId)
return timelineItem.copy(
senderProfile = senderProfile,
senderAvatar = timelineItem.senderAvatar.copy(
name = senderProfile.getDisambiguatedDisplayName(timelineItem.senderId),
url = senderProfile.getAvatarUrl(),
),
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 {
if (!config.computeReactions) {
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.timeline.MatrixTimelineItem
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_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.anEventTimelineItem
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> {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(