Feature: add room threads list (#6575)
Add threads list screen for rooms: - Add `ThreadsListService` to subscribe to thread changes in the room. - Create `ThreadsListView` and its associated node a presenters (the UI may change). - Add a menu icon in the room screen to open it. This is still pending info about unread threads, so several UI components related to it will be hidden. * Add feature flag and use it to hide the access to this new screen --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
be775d686e
commit
80470b3792
35 changed files with 1357 additions and 45 deletions
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
|
|
@ -44,8 +45,10 @@ import io.element.android.libraries.matrix.impl.room.history.map
|
|||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
|
||||
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||
|
|
@ -145,6 +148,12 @@ class JoinedRustRoom(
|
|||
|
||||
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
|
||||
|
||||
override val threadsListService: ThreadsListService = RustThreadsListService(
|
||||
inner = innerRoom.threadListService(),
|
||||
contentMapper = TimelineEventContentMapper(),
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
)
|
||||
|
||||
override val syncUpdateFlow = flow {
|
||||
var counter = 0L
|
||||
liveTimeline.onSyncedEventReceived.collect {
|
||||
|
|
@ -528,6 +537,7 @@ class JoinedRustRoom(
|
|||
override fun destroy() {
|
||||
baseRoom.destroy()
|
||||
liveInnerTimeline.destroy()
|
||||
threadsListService.destroy()
|
||||
Timber.d("Room $roomId destroyed")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* 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.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.ThreadListItem
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||
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 org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||
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<List<ThreadListItem>>(emptyList())
|
||||
|
||||
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
|
||||
if (itemSubscriptionJob?.isActive != true) {
|
||||
itemSubscriptionJob = doSubscribeToItemUpdates()
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun doSubscribeToItemUpdates(): Job {
|
||||
val updatesFlow = mxCallbackFlow {
|
||||
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
|
||||
override fun onUpdate(diff: List<ThreadListUpdate>) {
|
||||
trySend(diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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<ThreadListPaginationStatus> {
|
||||
return mxCallbackFlow {
|
||||
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
|
||||
override fun onUpdate(state: ThreadListPaginationState) {
|
||||
trySend(state.map())
|
||||
}
|
||||
}).also {
|
||||
// Send the initial state
|
||||
trySend(inner.paginationState().map())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
|
||||
inner.paginate()
|
||||
}
|
||||
|
||||
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
|
||||
inner.reset()
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
itemSubscriptionJob?.cancel()
|
||||
inner.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<ThreadListItem>.apply(
|
||||
diff: List<ThreadListUpdate>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
|
||||
rootEvent = rootEvent.map(contentMapper),
|
||||
latestEvent = latestEvent?.map(contentMapper),
|
||||
numberOfReplies = numReplies.toLong(),
|
||||
)
|
||||
|
||||
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
|
||||
eventId = EventId(eventId),
|
||||
senderId = UserId(sender),
|
||||
isOwn = isOwn,
|
||||
senderProfile = senderProfile.map(),
|
||||
content = content?.let(contentMapper::map),
|
||||
timestamp = timestamp.toLong(),
|
||||
)
|
||||
|
||||
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
|
||||
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
|
||||
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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.fakes
|
||||
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListItem
|
||||
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListService
|
||||
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||
|
||||
class FakeFfiThreadListService(
|
||||
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
|
||||
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
|
||||
private val items: () -> List<ThreadListItem> = { emptyList() },
|
||||
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
|
||||
private val paginate: suspend () -> Unit = {},
|
||||
private val reset: suspend () -> Unit = {},
|
||||
private val destroy: () -> Unit = {},
|
||||
) : ThreadListService(NoHandle) {
|
||||
private var itemsListener: ThreadListEntriesListener? = null
|
||||
private var paginationStateListener: ThreadListPaginationStateListener? = null
|
||||
|
||||
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
|
||||
itemsListener = listener
|
||||
return subscribeToItemsUpdates.invoke(listener)
|
||||
}
|
||||
|
||||
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
|
||||
paginationStateListener = listener
|
||||
return subscribeToPaginationStateUpdates.invoke(listener)
|
||||
}
|
||||
|
||||
override fun items(): List<ThreadListItem> = items.invoke()
|
||||
|
||||
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
|
||||
|
||||
override suspend fun paginate() = paginate.invoke()
|
||||
|
||||
override suspend fun reset() = reset.invoke()
|
||||
|
||||
override fun destroy() = destroy.invoke()
|
||||
|
||||
fun emitUpdates(updates: List<ThreadListUpdate>) {
|
||||
itemsListener?.onUpdate(updates)
|
||||
}
|
||||
|
||||
fun emitPaginationState(state: ThreadListPaginationState) {
|
||||
paginationStateListener?.onUpdate(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* 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.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<ThreadListEntriesListener, TaskHandle> { 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<ThreadListEntriesListener, TaskHandle> { 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<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.subscribeToPaginationUpdates().test {
|
||||
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
|
||||
|
||||
runCurrent()
|
||||
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
|
||||
|
||||
inner.emitPaginationState(ThreadListPaginationState.Loading)
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginate calls the FFI method`() = runTest {
|
||||
val paginateRecorder = lambdaRecorder<Unit> {}
|
||||
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.paginate()
|
||||
|
||||
paginateRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset calls the FFI method`() = runTest {
|
||||
val resetRecorder = lambdaRecorder<Unit> {}
|
||||
val inner = FakeFfiThreadListService(reset = resetRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.reset()
|
||||
|
||||
resetRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `destroy calls the FFI method`() = runTest {
|
||||
val destroyRecorder = lambdaRecorder<Unit> {}
|
||||
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.destroy()
|
||||
|
||||
destroyRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun TestScope.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,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue