Skip to content
Open
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 @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<ThreadListItem>()
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,53 +43,22 @@ class ThreadsListPresenter(
private val timelineItemContentFactory: TimelineItemContentFactory,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<ThreadsListState> {
@Composable
override fun present(): ThreadsListState {
val coroutineScope = rememberCoroutineScope()
val threadsListService = room.threadsListService

val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) {
threadsListService.subscribeToItemUpdates()
val rows = mutableListOf<ThreadListRowItem>()
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()
}
}

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<AvatarData> = persistentListOf(),
threads: ImmutableList<ThreadListRowItem> = 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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -42,7 +46,10 @@ class ThreadsListPresenterTest {
val paginateRecorder = lambdaRecorder<Result<Unit>> { 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
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<ThreadListItem>) : 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<ThreadListItem>) : ThreadListDiff
}

suspend fun <T> MutableList<T>.applyDiffs(
diffs: List<ThreadListDiff>,
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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<ThreadListItem>>
fun subscribeToItemDiffs(): Flow<List<ThreadListDiff>>
Comment thread
bxdxnn marked this conversation as resolved.

/** The pagination state of the list, emitting the current value immediately so callers know whether more threads can be loaded. */
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>

/** 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<Unit>

/** Discards the loaded threads and starts the list again from the most recent one. */
Expand Down
Loading
Loading