Space list (#5320)

* feature(spaces) : introduce SpaceRoomList matrix api

* feature (space) : extract SpaceRoomItemView

* feature(spaces) : start introducing SpaceScreen

* feature (space) : iterate on space list (and space screen)

* feature (space) : add space cache and navigation to sub space/room

* feature (space) : display top bar title

* Code cleanup, remove dead code and fix compilation issue

* More compilation fixes.

* Update screenshots

* Fix test compilation issues.

* Introduce MatrixClient.rememberHideInvitesAvatar() extension to reduce code duplication.

* Add test on SpacePresenter

* Add test on SpaceRoomCache and fix implementation

* Iterate on SpaceRoomCache thanks to SpaceRoomCacheTest

* Add UT on SpaceListUpdateProcessor

* Fix quality issue.

* Add tests on RustSpaceRoomList

---------

Co-authored-by: ganfra <francoisg@matrix.org>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-09-11 17:29:15 +02:00 committed by GitHub
commit 3af4405ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1840 additions and 286 deletions

View file

@ -0,0 +1,80 @@
/*
* Copyright 2025 New Vector 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.spaces
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomList(
private val roomId: RoomId,
private val innerProvider: suspend () -> InnerSpaceRoomList,
sessionCoroutineScope: CoroutineScope,
spaceRoomMapper: SpaceRoomMapper,
private val spaceRoomCache: SpaceRoomCache,
) : SpaceRoomList {
private val inner = CompletableDeferred<InnerSpaceRoomList>()
override fun currentSpaceFlow(): Flow<SpaceRoom?> {
return spaceRoomCache.getSpaceRoomFlow(roomId)
}
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
override val paginationStatusFlow: MutableStateFlow<SpaceRoomList.PaginationStatus> =
MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false))
private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
spaceRoomsFlow = spaceRoomsFlow,
mapper = spaceRoomMapper,
spaceRoomCache = spaceRoomCache
)
init {
sessionCoroutineScope.launch {
inner.complete(innerProvider())
}
sessionCoroutineScope.launch {
inner.await().paginationStateFlow()
.onEach { paginationStatus ->
paginationStatusFlow.emit(paginationStatus.into())
}
.collect()
}
sessionCoroutineScope.launch {
inner.await().spaceListUpdateFlow()
.onEach { updates ->
spaceListUpdateProcessor.postUpdates(updates)
}
.collect()
}
}
override suspend fun paginate(): Result<Unit> {
return runCatchingExceptions {
inner.await().paginate()
}
}
private fun SpaceRoomListPaginationState.into(): SpaceRoomList.PaginationStatus {
return when (this) {
is SpaceRoomListPaginationState.Idle -> SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = !endReached)
SpaceRoomListPaginationState.Loading -> SpaceRoomList.PaginationStatus.Loading
}
}
}

View file

@ -8,7 +8,9 @@
package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineDispatcher
@ -21,11 +23,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import org.matrix.rustcomponents.sdk.SpaceServiceInterface
@ -38,104 +37,55 @@ class RustSpaceService(
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
) : SpaceService {
private val mapper = SpaceRoomMapper()
private val mutex = Mutex()
override val spaceRooms = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
private val spaceRoomMapper = SpaceRoomMapper()
private val spaceRoomCache = SpaceRoomCache()
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
spaceRoomsFlow = spaceRoomsFlow,
mapper = spaceRoomMapper,
spaceRoomCache = spaceRoomCache
)
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.joinedSpaces()
.map {
it.let(mapper::map)
it.let(spaceRoomMapper::map)
}
}
}
// override suspend fun spaceRoomList(spaceId: SpaceId): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
// runCatchingExceptions {
// innerSpaceService.spaceRoomList(spaceId.value)
// }
// }
override fun spaceRoomList(id: RoomId): SpaceRoomList {
return RustSpaceRoomList(
roomId = id,
innerProvider = { innerSpaceService.spaceRoomList(id.value) },
sessionCoroutineScope = sessionCoroutineScope,
spaceRoomMapper = spaceRoomMapper,
spaceRoomCache = spaceRoomCache,
)
}
init {
innerSpaceService
.spaceDiffFlow()
.onEach {
handeUpdate(it)
.spaceListUpdate()
.onEach { updates ->
spaceListUpdateProcessor.postUpdates(updates)
}
.launchIn(sessionCoroutineScope)
}
private suspend fun handeUpdate(spaceListUpdates: List<SpaceListUpdate>) {
mutex.withLock {
val current = if (spaceRooms.replayCache.isNotEmpty()) {
spaceRooms.first().toMutableList()
} else {
mutableListOf()
}
spaceListUpdates.forEach { update ->
current.applyUpdate(update)
}
spaceRooms.emit(current)
}
}
private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {
when (update) {
is SpaceListUpdate.Append -> {
val newSpaces = update.values.map(mapper::map)
addAll(newSpaces)
}
SpaceListUpdate.Clear -> clear()
is SpaceListUpdate.Insert -> {
val newSpace = mapper.map(update.value)
add(update.index.toInt(), newSpace)
}
SpaceListUpdate.PopBack -> {
removeAt(lastIndex)
}
SpaceListUpdate.PopFront -> {
removeAt(0)
}
is SpaceListUpdate.PushBack -> {
val newSpace = mapper.map(update.value)
add(newSpace)
}
is SpaceListUpdate.PushFront -> {
val newSpace = mapper.map(update.value)
add(0, newSpace)
}
is SpaceListUpdate.Remove -> {
removeAt(update.index.toInt())
}
is SpaceListUpdate.Reset -> {
clear()
val newSpaces = update.values.map(mapper::map)
addAll(newSpaces)
}
is SpaceListUpdate.Set -> {
val newSpace = mapper.map(update.value)
this[update.index.toInt()] = newSpace
}
is SpaceListUpdate.Truncate -> {
subList(update.length.toInt(), size).clear()
}
}
}
}
internal fun SpaceServiceInterface.spaceDiffFlow(): Flow<List<SpaceListUpdate>> =
internal fun SpaceServiceInterface.spaceListUpdate(): Flow<List<SpaceListUpdate>> =
callbackFlow {
val listener = object : SpaceServiceJoinedSpacesListener {
override fun onUpdate(roomUpdates: List<SpaceListUpdate>) {
trySendBlocking(roomUpdates)
}
}
Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceDiffFlow}")
Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}")
val taskHandle = subscribeToJoinedSpaces(listener)
awaitClose {
Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceDiffFlow}")
Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}")
taskHandle.cancelAndDestroy()
}
}.catch {

View file

@ -0,0 +1,86 @@
/*
* Copyright 2023, 2024 New Vector 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.spaces
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import timber.log.Timber
internal class SpaceListUpdateProcessor(
private val spaceRoomsFlow: MutableSharedFlow<List<SpaceRoom>>,
private val mapper: SpaceRoomMapper,
private val spaceRoomCache: SpaceRoomCache,
) {
private val mutex = Mutex()
suspend fun postUpdates(updates: List<SpaceListUpdate>) {
Timber.v("Update space rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
updateSpaceRooms {
updates.forEach { update -> applyUpdate(update) }
}
}
private suspend fun updateSpaceRooms(block: MutableList<SpaceRoom>.() -> Unit) =
mutex.withLock {
val spaceRooms = if (spaceRoomsFlow.replayCache.isNotEmpty()) {
spaceRoomsFlow.first().toMutableList()
} else {
mutableListOf()
}
block(spaceRooms)
spaceRoomCache.update(spaceRooms)
spaceRoomsFlow.emit(spaceRooms)
}
private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {
when (update) {
is SpaceListUpdate.Append -> {
val newSpaces = update.values.map(mapper::map)
addAll(newSpaces)
}
SpaceListUpdate.Clear -> clear()
is SpaceListUpdate.Insert -> {
val newSpace = mapper.map(update.value)
add(update.index.toInt(), newSpace)
}
SpaceListUpdate.PopBack -> {
removeAt(lastIndex)
}
SpaceListUpdate.PopFront -> {
removeAt(0)
}
is SpaceListUpdate.PushBack -> {
val newSpace = mapper.map(update.value)
add(newSpace)
}
is SpaceListUpdate.PushFront -> {
val newSpace = mapper.map(update.value)
add(0, newSpace)
}
is SpaceListUpdate.Remove -> {
removeAt(update.index.toInt())
}
is SpaceListUpdate.Reset -> {
clear()
val newSpaces = update.values.map(mapper::map)
addAll(newSpaces)
}
is SpaceListUpdate.Set -> {
val newSpace = mapper.map(update.value)
this[update.index.toInt()] = newSpace
}
is SpaceListUpdate.Truncate -> {
subList(update.length.toInt(), size).clear()
}
}
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector 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.spaces
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* An in memory cache of space rooms.
* Only caches Rooms with roomType [io.element.android.libraries.matrix.api.room.RoomType.Space].
*/
class SpaceRoomCache {
private val inMemoryCache = MutableStateFlow<Map<RoomId, SpaceRoom?>>(emptyMap())
fun getSpaceRoomFlow(roomId: RoomId): Flow<SpaceRoom?> {
return inMemoryCache.mapState { it[roomId] }
}
fun update(spaceRooms: List<SpaceRoom>) {
inMemoryCache.update { currentValues ->
val newValues = spaceRooms
.filter { it.isSpace }
.associateBy { it.roomId }
currentValues + newValues
}
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright 2025 New Vector 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.spaces
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener
import org.matrix.rustcomponents.sdk.SpaceRoomListInterface
import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener
import timber.log.Timber
import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
internal fun SpaceRoomListInterface.paginationStateFlow(): Flow<SpaceRoomListPaginationState> = callbackFlow {
val listener = object : SpaceRoomListPaginationStateListener {
override fun onUpdate(paginationState: SpaceRoomListPaginationState) {
trySend(paginationState)
}
}
// Send the initial value
trySend(paginationState())
// Then subscribe to updates
val result = subscribeToPaginationStateUpdates(listener)
awaitClose {
result.cancelAndDestroy()
}
}.catch {
Timber.d(it, "paginationStateFlow() failed")
}.buffer(Channel.UNLIMITED)
internal fun SpaceRoomListInterface.spaceListUpdateFlow(): Flow<List<SpaceListUpdate>> =
callbackFlow {
val listener = object : SpaceRoomListEntriesListener {
override fun onUpdate(rooms: List<SpaceListUpdate>) {
trySendBlocking(rooms)
}
}
Timber.d("Open spaceListUpdateFlow for SpaceRoomListInterface ${this@spaceListUpdateFlow}")
val taskHandle = subscribeToRoomUpdate(listener)
awaitClose {
Timber.d("Close spaceListUpdateFlow for SpaceRoomListInterface ${this@spaceListUpdateFlow}")
taskHandle.cancelAndDestroy()
}
}.catch {
Timber.d(it, "spaceListUpdateFlow() failed")
}.buffer(Channel.UNLIMITED)

View file

@ -9,7 +9,7 @@ package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.map
@ -26,7 +26,7 @@ class SpaceRoomMapper {
joinRule = spaceRoom.joinRule?.map(),
name = spaceRoom.name,
numJoinedMembers = spaceRoom.numJoinedMembers.toInt(),
spaceId = spaceRoom.roomId.let(::SpaceId),
roomId = RoomId(spaceRoom.roomId),
roomType = spaceRoom.roomType.map(),
state = spaceRoom.state?.map(),
topic = spaceRoom.topic,