diff --git a/changelog.d/2176.bugfix b/changelog.d/2176.bugfix new file mode 100644 index 0000000000..7588dfdd3a --- /dev/null +++ b/changelog.d/2176.bugfix @@ -0,0 +1 @@ +Update timeline items' read receipts when the room members info is loaded. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 85b555fa7c..f1f934c4ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -111,8 +112,6 @@ class TimelinePresenter @AssistedInject constructor( } } - val membersState by room.membersStateFlow.collectAsState() - fun handleEvents(event: TimelineEvents) { when (event) { TimelineEvents.LoadMore -> localScope.paginateBackwards() @@ -149,13 +148,12 @@ class TimelinePresenter @AssistedInject constructor( } LaunchedEffect(Unit) { - timeline - .timelineItems - .onEach { + combine(timeline.timelineItems, room.membersStateFlow) { items, membersState -> timelineItemsFactory.replaceWith( - timelineItems = it, + timelineItems = items, roomMembers = membersState.roomMembers().orEmpty() ) + items } .onEach { timelineItems -> if (timelineItems.isEmpty()) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index ffc1a1b3f1..29ea3abcfc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -87,7 +87,16 @@ class TimelineItemsFactory @Inject constructor( newTimelineItemStates.add(timelineItemState) } } else { - newTimelineItemStates.add(cacheItem) + val updatedItem = if (cacheItem is TimelineItem.Event && roomMembers.isNotEmpty()) { + eventItemFactory.update( + timelineItem = cacheItem, + receivedMatrixTimelineItem = timelineItems[index] as MatrixTimelineItem.Event, + roomMembers = roomMembers + ) + } else { + cacheItem + } + newTimelineItemStates.add(updatedItem) } } val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 29ea9df6c2..38125dcef7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -53,21 +53,7 @@ class TimelineItemEventFactory @Inject constructor( val currentSender = currentTimelineItem.event.sender val groupPosition = computeGroupPosition(currentTimelineItem, timelineItems, index) - val senderDisplayName: String? - val senderAvatarUrl: String? - - when (val senderProfile = currentTimelineItem.event.senderProfile) { - ProfileTimelineDetails.Unavailable, - ProfileTimelineDetails.Pending, - is ProfileTimelineDetails.Error -> { - senderDisplayName = null - senderAvatarUrl = null - } - is ProfileTimelineDetails.Ready -> { - senderDisplayName = senderProfile.getDisambiguatedDisplayName(currentSender) - senderAvatarUrl = senderProfile.avatarUrl - } - } + val (senderDisplayName, senderAvatarUrl) = currentTimelineItem.getSenderInfo() val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp)) @@ -101,6 +87,36 @@ class TimelineItemEventFactory @Inject constructor( ) } + fun update( + timelineItem: TimelineItem.Event, + receivedMatrixTimelineItem: MatrixTimelineItem.Event, + roomMembers: List, + ): TimelineItem.Event { + return timelineItem.copy( + readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers) + ) + } + + private fun MatrixTimelineItem.Event.getSenderInfo(): Pair { + val senderDisplayName: String? + val senderAvatarUrl: String? + + when (val senderProfile = event.senderProfile) { + ProfileTimelineDetails.Unavailable, + ProfileTimelineDetails.Pending, + is ProfileTimelineDetails.Error -> { + senderDisplayName = null + senderAvatarUrl = null + } + is ProfileTimelineDetails.Ready -> { + senderDisplayName = senderProfile.getDisambiguatedDisplayName(event.sender) + senderAvatarUrl = senderProfile.avatarUrl + } + } + + return senderDisplayName to senderAvatarUrl + } + private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) var aggregatedReactions = event.reactions.map { reaction -> diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 5f68622c4d..be06965fa6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -35,14 +35,18 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender +import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem @@ -60,6 +64,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import java.util.Date +import kotlin.time.Duration.Companion.seconds private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID" @@ -353,6 +358,50 @@ class TimelinePresenterTest { } } + @Test + fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { + val timeline = FakeMatrixTimeline( + listOf( + MatrixTimelineItem.Event( + FAKE_UNIQUE_ID, + anEventTimelineItem( + sender = A_USER_ID, + receipts = persistentListOf( + Receipt( + userId = A_USER_ID, + timestamp = 0L, + ) + ) + ) + ) + ) + ) + val room = FakeMatrixRoom(matrixTimeline = timeline).apply { + givenRoomMembersState(MatrixRoomMembersState.Unknown) + } + + val avatarUrl = "https://domain.com/avatar.jpg" + + val presenter = createTimelinePresenter(timeline, room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last() + val event = initialState.timelineItems.first() as TimelineItem.Event + assertThat(event.senderAvatar.url).isNull() + assertThat(event.readReceiptState.receipts.first().avatarData.url).isNull() + + room.givenRoomMembersState( + MatrixRoomMembersState.Ready( + persistentListOf(aRoomMember(userId = A_USER_ID, avatarUrl = avatarUrl)) + ) + ) + + val updatedEvent = awaitItem().timelineItems.first() as TimelineItem.Event + assertThat(updatedEvent.readReceiptState.receipts.first().avatarData.url).isEqualTo(avatarUrl) + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { // Skip 1 item if Mentions feature is enabled if (FeatureFlags.Mentions.defaultValue) { @@ -363,6 +412,7 @@ class TimelinePresenterTest { private fun TestScope.createTimelinePresenter( timeline: MatrixTimeline = FakeMatrixTimeline(), + room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline), timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(), redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(), @@ -371,7 +421,7 @@ class TimelinePresenterTest { ): TimelinePresenter { return TimelinePresenter( timelineItemsFactory = timelineItemsFactory, - room = FakeMatrixRoom(matrixTimeline = timeline), + room = room, dispatchers = testCoroutineDispatchers(), appScope = this, navigator = messagesNavigator,