Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList
Expand Down Expand Up @@ -75,19 +76,22 @@ class TimelineItemsFactory(
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
) {
// Index the member list once per batch so per-item sender / receipt lookups are O(1)
// rather than O(N) — N can reach tens of thousands in large public rooms.
val roomMembersById = roomMembers.associateBy { it.userId }
val newTimelineItemStates = ArrayList<TimelineItem>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
buildAndCacheItem(timelineItems, index, roomMembersById)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
val updatedItem = if (cacheItem is TimelineItem.Event && roomMembers.isNotEmpty()) {
val updatedItem = if (cacheItem is TimelineItem.Event && roomMembersById.isNotEmpty()) {
eventItemFactory.update(
timelineItem = cacheItem,
receivedMatrixTimelineItem = timelineItems[index] as MatrixTimelineItem.Event,
roomMembers = roomMembers
roomMembersById = roomMembersById,
)
} else {
cacheItem
Expand All @@ -102,11 +106,11 @@ class TimelineItemsFactory(
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int,
roomMembers: List<RoomMember>,
roomMembersById: Map<UserId, RoomMember>,
): TimelineItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembersById)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ 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.room.RoomMembershipState
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
Expand All @@ -56,12 +59,16 @@ class TimelineItemEventFactory(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
roomMembersById: Map<UserId, RoomMember>,
): TimelineItem.Event {
val currentSender = currentTimelineItem.event.sender
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderProfile = currentTimelineItem.event.senderProfile
val (senderProfile, senderAvatarData) = resolveSender(
sender = currentSender,
eventSenderProfile = currentTimelineItem.event.senderProfile,
roomMembersById = roomMembersById,
)
val sentTime = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly,
Expand All @@ -71,12 +78,6 @@ class TimelineItemEventFactory(
mode = DateFormatterMode.Day,
useRelative = true,
)
val senderAvatarData = AvatarData(
id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender),
url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender
)
val mappedThreadInfo = when (val threadInfo = currentTimelineItem.event.threadInfo()) {
is EventThreadInfo.ThreadResponse -> {
TimelineItemThreadInfo.ThreadResponse(threadInfo.threadRootId)
Expand Down Expand Up @@ -116,7 +117,7 @@ class TimelineItemEventFactory(
sentDate = sentDate,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembersById),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = mappedThreadInfo,
Expand All @@ -132,11 +133,43 @@ class TimelineItemEventFactory(
fun update(
timelineItem: TimelineItem.Event,
receivedMatrixTimelineItem: MatrixTimelineItem.Event,
roomMembers: List<RoomMember>,
roomMembersById: Map<UserId, RoomMember>,
): TimelineItem.Event {
// Recompute the sender profile so that avatar / display name updates propagate to rows
// already in the diff cache. The avatar is also rebuilt because it derives from senderProfile.
val (senderProfile, senderAvatarData) = resolveSender(
sender = timelineItem.senderId,
eventSenderProfile = receivedMatrixTimelineItem.event.senderProfile,
roomMembersById = roomMembersById,
)
return timelineItem.copy(
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers)
senderProfile = senderProfile,
senderAvatar = senderAvatarData,
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembersById),
)
}

/**
* Resolves the sender's [ProfileDetails] and [AvatarData] for a timeline event, preferring the
* live [RoomMember] over the event-level snapshot so that avatar / display name updates
* propagate to existing rows. Falls back to the event snapshot when the sender isn't in the
* member list (e.g. they have left the room).
*/
private fun resolveSender(
sender: UserId,
eventSenderProfile: ProfileDetails,
roomMembersById: Map<UserId, RoomMember>,
): Pair<ProfileDetails, AvatarData> {
val senderProfile = eventSenderProfile.withLiveMemberOverride(
roomMembersById[sender]
)
val avatarData = AvatarData(
id = sender.value,
name = senderProfile.getDisambiguatedDisplayName(sender),
url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender,
)
return senderProfile to avatarData
}

private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
Expand Down Expand Up @@ -179,15 +212,15 @@ class TimelineItemEventFactory(
}

private fun MatrixTimelineItem.Event.computeReadReceiptState(
roomMembers: List<RoomMember>,
roomMembersById: Map<UserId, RoomMember>,
): TimelineItemReadReceipts {
if (!config.computeReadReceipts) {
return TimelineItemReadReceipts(receipts = persistentListOf())
}
return TimelineItemReadReceipts(
receipts = event.receipts
.map { receipt ->
val roomMember = roomMembers.find { it.userId == receipt.userId }
val roomMember = roomMembersById[receipt.userId]
ReadReceiptData(
avatarData = AvatarData(
id = receipt.userId.value,
Expand Down Expand Up @@ -256,3 +289,21 @@ class TimelineItemEventFactory(
}
}
}

/**
* Returns a [ProfileDetails.Ready] sourced from the live [RoomMember] when available, otherwise
* the original event-level snapshot. This lets timeline rows reflect avatar / display name updates
* that arrived via sync after the event was first cached. Banned members are skipped so the
* existing convention of hiding their identity (see [RoomMember.toMatrixUser]) is preserved.
*/
internal fun ProfileDetails.withLiveMemberOverride(liveMember: RoomMember?): ProfileDetails {
return if (liveMember == null || liveMember.membership == RoomMembershipState.BAN) {
this
} else {
ProfileDetails.Ready(
displayName = liveMember.displayName,
displayNameAmbiguous = liveMember.isNameAmbiguous,
avatarUrl = liveMember.avatarUrl,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.messages.impl.timeline.factories.event

import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.test.room.aRoomMember
import org.junit.Test

class TimelineItemEventFactoryTest {
private val eventProfile = ProfileDetails.Ready(
displayName = "Stale name",
displayNameAmbiguous = false,
avatarUrl = "mxc://example.org/stale",
)

@Test
fun `withLiveMemberOverride uses the live member when present`() {
val liveMember = aRoomMember(
userId = UserId("@bob:server.org"),
displayName = "Fresh name",
avatarUrl = "mxc://example.org/fresh",
)

val result = eventProfile.withLiveMemberOverride(liveMember)

assertThat(result).isEqualTo(
ProfileDetails.Ready(
displayName = "Fresh name",
displayNameAmbiguous = false,
avatarUrl = "mxc://example.org/fresh",
)
)
}

@Test
fun `withLiveMemberOverride falls back to the event snapshot when no live member`() {
val result = eventProfile.withLiveMemberOverride(liveMember = null)

assertThat(result).isSameInstanceAs(eventProfile)
}

@Test
fun `withLiveMemberOverride falls back when the live member is banned`() {
val banned = aRoomMember(
userId = UserId("@bob:server.org"),
displayName = "Should be hidden",
avatarUrl = "mxc://example.org/banned",
membership = RoomMembershipState.BAN,
)

val result = eventProfile.withLiveMemberOverride(banned)

assertThat(result).isSameInstanceAs(eventProfile)
}

@Test
fun `withLiveMemberOverride propagates display-name ambiguity from the live member`() {
val liveMember = aRoomMember(
userId = UserId("@bob:server.org"),
displayName = "Bob",
avatarUrl = null,
isNameAmbiguous = true,
)

val result = eventProfile.withLiveMemberOverride(liveMember)

assertThat(result).isEqualTo(
ProfileDetails.Ready(
displayName = "Bob",
displayNameAmbiguous = true,
avatarUrl = null,
)
)
}

@Test
fun `withLiveMemberOverride starts from Unavailable snapshot and still uses live data`() {
val liveMember = aRoomMember(
userId = UserId("@bob:server.org"),
displayName = "Fresh name",
avatarUrl = "mxc://example.org/fresh",
)

val result = ProfileDetails.Unavailable.withLiveMemberOverride(liveMember)

assertThat(result).isEqualTo(
ProfileDetails.Ready(
displayName = "Fresh name",
displayNameAmbiguous = false,
avatarUrl = "mxc://example.org/fresh",
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ interface Timeline : AutoCloseable {
}

val mode: Mode

/**
* Emits when a room member event arrives via sync that may have changed the cached member list,
* including both actual membership transitions (join/leave/invite/ban) and profile-only changes
* (display name / avatar). Subscribers should refetch members so that observers of
* [io.element.android.libraries.matrix.api.room.BaseRoom.membersStateFlow] see fresh data.
*/
val membershipChangeEventReceived: Flow<Unit>
val onSyncedEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.timeline

import androidx.compose.ui.util.fastForEach
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.coroutines.flow.MutableSharedFlow
Expand Down Expand Up @@ -158,7 +159,8 @@ private class DiffingResult(initialItems: List<MatrixTimelineItem>) {
if (item.event.origin == TimelineItemEventOrigin.SYNC) {
hasNewEventsFromSync = true
when (item.event.content) {
is RoomMembershipContent -> hasMembershipChangeEventFromSync = true
is RoomMembershipContent,
is ProfileChangeContent -> hasMembershipChangeEventFromSync = true
else -> Unit
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.matrix.impl.fixtures.factories

import io.element.android.libraries.matrix.test.A_USER_ID
import org.matrix.rustcomponents.sdk.MembershipChange
import org.matrix.rustcomponents.sdk.TimelineItemContent

internal fun aRustTimelineItemContentProfileChange(
displayName: String? = "new-name",
prevDisplayName: String? = "old-name",
avatarUrl: String? = "mxc://example.org/new",
prevAvatarUrl: String? = "mxc://example.org/old",
) = TimelineItemContent.ProfileChange(
displayName = displayName,
prevDisplayName = prevDisplayName,
avatarUrl = avatarUrl,
prevAvatarUrl = prevAvatarUrl,
)

internal fun aRustTimelineItemContentRoomMembership(
userId: String = A_USER_ID.value,
userDisplayName: String? = "name",
change: MembershipChange? = MembershipChange.JOINED,
reason: String? = null,
) = TimelineItemContent.RoomMembership(
userId = userId,
userDisplayName = userDisplayName,
change = change,
reason = reason,
)
Loading
Loading