diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index dd3afb0ea6b..5389d85acbe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -81,6 +81,8 @@ import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.applyDiffs import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map @@ -93,7 +95,6 @@ import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -170,9 +171,13 @@ class MessagesPresenter( val roomCallState = roomCallStatePresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present() val threadsList by produceState(persistentListOf()) { - room.threadsListService.subscribeToItemUpdates() + val items = mutableListOf() + room.threadsListService.subscribeToItemDiffs() .onStart { room.threadsListService.paginate() } - .collectLatest { value = it.toImmutableList() } + .collect { diffs -> + items.applyDiffs(diffs) { it } + value = items.toImmutableList() + } } val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt index ba10df46f29..51aed6b685c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt @@ -18,19 +18,23 @@ import dev.zacsweers.metro.Inject import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.api.DateFormatter 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.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.api.room.threads.applyDiffs import io.element.android.libraries.matrix.ui.model.getAvatarData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber @Inject @@ -39,6 +43,7 @@ class ThreadsListPresenter( private val timelineItemContentFactory: TimelineItemContentFactory, private val messageSummaryFormatter: MessageSummaryFormatter, private val dateFormatter: DateFormatter, + private val coroutineDispatchers: CoroutineDispatchers, ) : Presenter { @Composable override fun present(): ThreadsListState { @@ -46,46 +51,14 @@ class ThreadsListPresenter( val threadsListService = room.threadsListService val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) { - threadsListService.subscribeToItemUpdates() + val rows = mutableListOf() + threadsListService.subscribeToItemDiffs() .onStart { threadsListService.paginate() } - .collect { items -> - Timber.d("Received thread list update with ${items.size} items") - value = items.map { item -> - val rootTimelineEvent = item.rootEvent.content?.let { - timelineItemContentFactory.create( - itemContent = it, - eventId = item.rootEvent.eventId, - isEditable = false, - sender = item.rootEvent.senderId, - senderProfile = item.rootEvent.senderProfile, - ) - } - val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) } - - val latestTimelineEvent = item.latestEvent?.content?.let { - timelineItemContentFactory.create( - itemContent = it, - eventId = item.latestEvent!!.eventId, - isEditable = false, - sender = item.latestEvent!!.senderId, - senderProfile = item.latestEvent!!.senderProfile, - ) - } - val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) } - - val formattedTimestamp = dateFormatter.format( - timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp, - mode = DateFormatterMode.TimeOrDate, - useRelative = true, - ) - - ThreadListRowItem( - item = item, - rootEventText = rootEventText, - latestEventText = latestEventText, - formattedTimestamp = formattedTimestamp, - ) - }.toImmutableList() + .collect { diffs -> + withContext(coroutineDispatchers.computation) { + rows.applyDiffs(diffs) { it.toRowItem() } + } + value = rows.toImmutableList() } } @@ -128,6 +101,35 @@ class ThreadsListPresenter( eventSink = ::handleEvent, ) } + + private suspend fun ThreadListItem.toRowItem() = ThreadListRowItem( + item = this, + rootEventText = rootEvent.content?.let { content -> + timelineItemContentFactory.create( + itemContent = content, + eventId = rootEvent.eventId, + isEditable = false, + sender = rootEvent.senderId, + senderProfile = rootEvent.senderProfile, + ).let { messageSummaryFormatter.format(it) } + }, + latestEventText = latestEvent?.let { latestEvent -> + latestEvent.content?.let { content -> + timelineItemContentFactory.create( + itemContent = content, + eventId = latestEvent.eventId, + isEditable = false, + sender = latestEvent.senderId, + senderProfile = latestEvent.senderProfile, + ).let { messageSummaryFormatter.format(it) } + } + }, + formattedTimestamp = dateFormatter.format( + timestamp = latestEvent?.timestamp ?: rootEvent.timestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true, + ), + ) } data class ThreadsListState( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt index 6480dacfcf7..a112bda6974 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt @@ -26,10 +26,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -48,7 +47,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.preview.ROOM_NAME import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon @@ -70,6 +68,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy 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.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @@ -167,19 +166,17 @@ private fun ScrollHelper( onPaginate: () -> Unit, ) { val updatedOnPaginate by rememberUpdatedState(onPaginate) - val lastVisibleItemIndex by remember { - derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 } - } - val shouldPaginate by remember { - derivedStateOf { - val canLoadNewItems = listState.isScrollInProgress || listState.layoutInfo.totalItemsCount == 0 - canLoadNewItems && lastVisibleItemIndex >= listState.layoutInfo.totalItemsCount - 1 - } - } - LaunchedEffect(shouldPaginate, lastVisibleItemIndex) { - if (shouldPaginate) { - updatedOnPaginate() - delay(400L) + LaunchedEffect(Unit) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val lastVisibleItemIndex = listState.firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size - 1 + val canLoadNewItems = listState.isScrollInProgress || layoutInfo.totalItemsCount == 0 + canLoadNewItems && lastVisibleItemIndex >= layoutInfo.totalItemsCount - 1 + }.collect { shouldPaginate -> + if (shouldPaginate) { + updatedOnPaginate() + delay(400L) + } } } } @@ -327,32 +324,13 @@ private fun ThreadListItemRow( internal fun ThreadsListViewPreview() { ElementPreview { ThreadsListView( - state = ThreadsListState( - roomId = RoomId("!room-id:server"), - roomName = ROOM_NAME, - roomAvatarUrl = null, - heroes = persistentListOf(), - threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(), - isRoomTombstoned = false, - eventSink = {}, - ), + state = aThreadsListState(), onThreadClick = {}, onBackClick = {}, ) } } -@PreviewsDayNight -@Composable -internal fun ThreadListItemRowPreview() { - ElementPreview { - ThreadListItemRow( - threadItem = aThreadListRowItem(), - onClick = {}, - ) - } -} - fun aThreadListRowItem( threadId: ThreadId = ThreadId("\$a-thread-id"), rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId), @@ -410,3 +388,21 @@ fun aThreadListItemEvent( content = content, timestamp = timestamp, ) + +fun aThreadsListState( + roomId: RoomId = RoomId("!room-id:server"), + roomName: String = "Room name", + roomAvatarUrl: String? = null, + isRoomTombstoned: Boolean = false, + heroes: ImmutableList = persistentListOf(), + threads: ImmutableList = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(), + eventSink: (ThreadsListEvents) -> Unit = {}, +) = ThreadsListState( + roomId = roomId, + roomName = roomName, + roomAvatarUrl = roomAvatarUrl, + isRoomTombstoned = isRoomTombstoned, + heroes = heroes, + threads = threads, + eventSink = eventSink, +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt index 9f8d210a168..ef712403505 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt @@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.messagesummary.FakeMessageSumma import io.element.android.features.messages.impl.threads.list.ThreadsListEvents import io.element.android.features.messages.impl.threads.list.ThreadsListPresenter import io.element.android.features.messages.impl.threads.list.aThreadListItem +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -21,13 +22,16 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.runTest import org.junit.Test class ThreadsListPresenterTest { @Test fun `present - initial state`() = runTest { - createThreadsListPresenter().test { + createThreadsListPresenter( + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + ).test { awaitItem().run { assertThat(threads).isEmpty() assertThat(roomId).isEqualTo(A_ROOM_ID) @@ -42,7 +46,10 @@ class ThreadsListPresenterTest { val paginateRecorder = lambdaRecorder> { Result.success(Unit) } val threadsListService = FakeThreadsListService(paginate = paginateRecorder) val room = FakeJoinedRoom(threadsListService = threadsListService) - createThreadsListPresenter(room).test { + createThreadsListPresenter( + room = room, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + ).test { val initialItem = awaitItem() // Pagination is automatically triggered on start, so we should have one call to paginate already @@ -63,12 +70,14 @@ class ThreadsListPresenterTest { private fun createThreadsListPresenter( room: FakeJoinedRoom = FakeJoinedRoom(), + coroutineDispatchers: CoroutineDispatchers, ): ThreadsListPresenter { return ThreadsListPresenter( room = room, timelineItemContentFactory = aTimelineItemContentFactory(), messageSummaryFormatter = FakeMessageSummaryFormatter(), dateFormatter = FakeDateFormatter(), + coroutineDispatchers = coroutineDispatchers, ) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListDiff.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListDiff.kt new file mode 100644 index 00000000000..e78a6b30d56 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListDiff.kt @@ -0,0 +1,52 @@ +/* + * 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.api.room.threads + +sealed interface ThreadListDiff { + data class Append(val values: List) : ThreadListDiff + data object Clear : ThreadListDiff + data class PushFront(val value: ThreadListItem) : ThreadListDiff + data class PushBack(val value: ThreadListItem) : ThreadListDiff + data object PopFront : ThreadListDiff + data object PopBack : ThreadListDiff + data class Insert(val index: Int, val value: ThreadListItem) : ThreadListDiff + data class Set(val index: Int, val value: ThreadListItem) : ThreadListDiff + data class Remove(val index: Int) : ThreadListDiff + data class Truncate(val length: Int) : ThreadListDiff + data class Reset(val values: List) : ThreadListDiff +} + +suspend fun MutableList.applyDiffs( + diffs: List, + transform: suspend (ThreadListItem) -> T, +) { + for (diff in diffs) { + when (diff) { + is ThreadListDiff.Reset -> { + clear() + for (value in diff.values) { + add(transform(value)) + } + } + is ThreadListDiff.Append -> { + for (value in diff.values) { + add(transform(value)) + } + } + is ThreadListDiff.Clear -> clear() + is ThreadListDiff.Insert -> add(diff.index, transform(diff.value)) + is ThreadListDiff.Set -> set(diff.index, transform(diff.value)) + is ThreadListDiff.Remove -> removeAt(diff.index) + is ThreadListDiff.PushBack -> add(transform(diff.value)) + is ThreadListDiff.PushFront -> add(0, transform(diff.value)) + is ThreadListDiff.PopBack -> if (isNotEmpty()) removeAt(lastIndex) + is ThreadListDiff.PopFront -> if (isNotEmpty()) removeAt(0) + is ThreadListDiff.Truncate -> if (diff.length < size) subList(diff.length, size).clear() + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt index f5442117417..06a7103717a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt @@ -16,15 +16,15 @@ import kotlinx.coroutines.flow.Flow */ interface ThreadsListService { /** - * The threads loaded so far, starting empty and re-emitted in full whenever the SDK sends an update. + * The list of changes to the threads loaded so far, emitted whenever the SDK sends an update. * The underlying subscription is started on the first call and shared by later ones. */ - fun subscribeToItemUpdates(): Flow> + fun subscribeToItemDiffs(): Flow> /** The pagination state of the list, emitting the current value immediately so callers know whether more threads can be loaded. */ fun subscribeToPaginationUpdates(): Flow - /** Loads the next page of threads, which will be reflected in [subscribeToItemUpdates]. */ + /** Loads the next page of threads, which will be reflected in [subscribeToItemDiffs]. */ suspend fun paginate(): Result /** Discards the loaded threads and starts the list again from the most recent one. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index d454d7c775a..36c5d9a597b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -157,7 +157,6 @@ class JoinedRustRoom( override val threadsListService: ThreadsListService = RustThreadsListService( inner = innerRoom.threadListService(), contentMapper = TimelineEventContentMapper(), - roomCoroutineScope = roomCoroutineScope, ) override val syncUpdateFlow = flow { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt index a74c5bc378d..d483d32057e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.room.threads import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.threads.ThreadListDiff import io.element.android.libraries.matrix.api.room.threads.ThreadListItem import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus @@ -17,13 +18,8 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.map import io.element.android.libraries.matrix.impl.util.mxCallbackFlow -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.map import org.matrix.rustcomponents.sdk.ThreadListEntriesListener import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener import org.matrix.rustcomponents.sdk.ThreadListUpdate @@ -32,38 +28,18 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService class RustThreadsListService( private val inner: InnerThreadListService, - private val roomCoroutineScope: CoroutineScope, private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), ) : ThreadsListService { - private var itemSubscriptionJob: Job? = null - - private val items = MutableStateFlow>(emptyList()) - - override fun subscribeToItemUpdates(): Flow> { - if (itemSubscriptionJob?.isActive != true) { - itemSubscriptionJob = doSubscribeToItemUpdates() - } - - return items - } - - private fun doSubscribeToItemUpdates(): Job { - val updatesFlow = mxCallbackFlow { + override fun subscribeToItemDiffs(): Flow> { + return mxCallbackFlow { inner.subscribeToItemsUpdates(object : ThreadListEntriesListener { override fun onUpdate(diff: List) { trySend(diff) } }) + }.map { diffs -> + diffs.map { it.toApiDiff(contentMapper) } } - - return updatesFlow - .onStart { items.value = inner.items().map { it.map(contentMapper) } } - .onEach { diff -> - val updated = items.value.toMutableList() - updated.apply(diff, contentMapper) - items.value = updated - } - .launchIn(roomCoroutineScope) } override fun subscribeToPaginationUpdates(): Flow { @@ -88,51 +64,41 @@ class RustThreadsListService( } override fun destroy() { - itemSubscriptionJob?.cancel() inner.destroy() } } -private fun MutableList.apply( - diff: List, - contentMapper: TimelineEventContentMapper -) { - for (diffItem in diff) { - when (diffItem) { - is ThreadListUpdate.Append -> { - val newItems = diffItem.values.map { it.map(contentMapper) } - addAll(newItems) - } - ThreadListUpdate.Clear -> clear() - is ThreadListUpdate.Insert -> { - add(diffItem.index.toInt(), diffItem.value.map(contentMapper)) - } - ThreadListUpdate.PopBack -> { - removeAt(lastIndex) - } - ThreadListUpdate.PopFront -> { - removeAt(0) - } - is ThreadListUpdate.PushBack -> { - add(diffItem.value.map(contentMapper)) - } - is ThreadListUpdate.PushFront -> { - add(0, diffItem.value.map(contentMapper)) - } - is ThreadListUpdate.Remove -> { - removeAt(diffItem.index.toInt()) - } - is ThreadListUpdate.Reset -> { - clear() - addAll(diffItem.values.map { it.map(contentMapper) }) - } - is ThreadListUpdate.Set -> { - set(diffItem.index.toInt(), diffItem.value.map(contentMapper)) - } - is ThreadListUpdate.Truncate -> { - subList(diffItem.length.toInt(), size).clear() - } - } +private fun ThreadListUpdate.toApiDiff(contentMapper: TimelineEventContentMapper): ThreadListDiff { + return when (this) { + is ThreadListUpdate.Append -> ThreadListDiff.Append( + values = values.map { it.map(contentMapper) } + ) + is ThreadListUpdate.Clear -> ThreadListDiff.Clear + is ThreadListUpdate.Insert -> ThreadListDiff.Insert( + index = index.toInt(), + value = value.map(contentMapper) + ) + is ThreadListUpdate.PushBack -> ThreadListDiff.PushBack( + value = value.map(contentMapper) + ) + is ThreadListUpdate.PushFront -> ThreadListDiff.PushFront( + value = value.map(contentMapper) + ) + is ThreadListUpdate.PopBack -> ThreadListDiff.PopBack + is ThreadListUpdate.PopFront -> ThreadListDiff.PopFront + is ThreadListUpdate.Remove -> ThreadListDiff.Remove( + index = index.toInt() + ) + is ThreadListUpdate.Reset -> ThreadListDiff.Reset( + values = values.map { it.map(contentMapper) } + ) + is ThreadListUpdate.Set -> ThreadListDiff.Set( + index = index.toInt(), + value = value.map(contentMapper) + ) + is ThreadListUpdate.Truncate -> ThreadListDiff.Truncate( + length = length.toInt() + ) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt index 0fc9ad06036..7603b6144f2 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -10,62 +10,19 @@ package io.element.android.libraries.matrix.impl.room.threads import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus -import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.A_TIMESTAMP -import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -import org.matrix.rustcomponents.sdk.ProfileDetails import org.matrix.rustcomponents.sdk.TaskHandle -import org.matrix.rustcomponents.sdk.ThreadListEntriesListener -import org.matrix.rustcomponents.sdk.ThreadListItem -import org.matrix.rustcomponents.sdk.ThreadListItemEvent import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener -import org.matrix.rustcomponents.sdk.ThreadListUpdate import uniffi.matrix_sdk_ui.ThreadListPaginationState @OptIn(ExperimentalCoroutinesApi::class) class RustThreadsListServiceTest { - @Test - fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest { - val subscribeToItemsUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } - val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder) - val service = createThreadsListService(inner = inner) - - service.subscribeToItemUpdates().test { - assertThat(awaitItem()).isEmpty() - - runCurrent() - subscribeToItemsUpdatesRecorder.assertions().isCalledOnce() - - inner.emitUpdates(listOf(aRustThreadListUpdate())) - - assertThat(awaitItem()).isNotEmpty() - } - } - - @Suppress("UnusedFlow") - @Test - fun `subscribing to item updates twice only calls the FFI method once`() = runTest { - val subscribeToItemsUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } - val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder) - val service = createThreadsListService(inner = inner) - - service.subscribeToItemUpdates() - service.subscribeToItemUpdates() - - runCurrent() - - subscribeToItemsUpdatesRecorder.assertions().isCalledOnce() - } - @Test fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest { val subscribeToPaginationUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } @@ -117,27 +74,9 @@ class RustThreadsListServiceTest { destroyRecorder.assertions().isCalledOnce() } - private fun TestScope.createThreadsListService( + private fun createThreadsListService( inner: FakeFfiThreadListService = FakeFfiThreadListService(), ) = RustThreadsListService( inner = inner, - roomCoroutineScope = backgroundScope, - ) - - private fun aRustThreadListUpdate() = ThreadListUpdate.Append( - values = listOf( - ThreadListItem( - rootEvent = ThreadListItemEvent( - eventId = AN_EVENT_ID.value, - timestamp = A_TIMESTAMP.toULong(), - sender = A_USER_ID.value, - senderProfile = ProfileDetails.Pending, - isOwn = true, - content = aRustTimelineItemContentMsgLike(), - ), - numReplies = 0u, - latestEvent = null, - ) - ), ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt index a1e719ffb2f..3ad549c266b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt @@ -7,23 +7,25 @@ package io.element.android.libraries.matrix.test.room.threads +import io.element.android.libraries.matrix.api.room.threads.ThreadListDiff import io.element.android.libraries.matrix.api.room.threads.ThreadListItem import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map class FakeThreadsListService( private val items: MutableStateFlow> = MutableStateFlow(emptyList()), private val paginationStatus: MutableStateFlow = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)), - private val subscribeToItemUpdates: () -> Flow> = { items }, + private val subscribeToItemDiffs: () -> Flow> = { items.map { listOf(ThreadListDiff.Reset(values = it)) } }, private val subscribeToPaginationUpdates: () -> Flow = { paginationStatus }, private val paginate: suspend () -> Result = { Result.success(Unit) }, private val reset: suspend () -> Result = { Result.success(Unit) }, private val destroy: () -> Unit = {}, ) : ThreadsListService { - override fun subscribeToItemUpdates(): Flow> { - return subscribeToItemUpdates.invoke() + override fun subscribeToItemDiffs(): Flow> { + return subscribeToItemDiffs.invoke() } override fun subscribeToPaginationUpdates(): Flow {