From aadd8b45e2aec690c335b0a403e80dea601ccd98 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 5 Sep 2025 10:27:33 +0200 Subject: [PATCH 01/17] feature(spaces) : introduce SpaceRoomList matrix api --- .../home/impl/spaces/HomeSpacesPresenter.kt | 5 +- .../home/impl/spaces/HomeSpacesState.kt | 3 +- .../impl/spaces/HomeSpacesStateProvider.kt | 14 +-- .../home/impl/spaces/HomeSpacesView.kt | 9 +- .../home/impl/spaces/SpaceRoomProvider.kt | 26 ++--- .../features/invite/api/SeenInvitesStore.kt | 6 -- .../libraries/matrix/api/spaces/SpaceRoom.kt | 4 +- .../matrix/api/spaces/SpaceRoomList.kt | 23 +++++ .../matrix/api/spaces/SpaceService.kt | 5 +- .../matrix/impl/spaces/RustSpaceRoomList.kt | 56 +++++++++++ .../matrix/impl/spaces/RustSpaceService.kt | 96 ++++--------------- .../impl/spaces/SpaceListUpdateProcessor.kt | 86 +++++++++++++++++ .../impl/spaces/SpaceRoomListExtensions.kt | 54 +++++++++++ .../matrix/impl/spaces/SpaceRoomMapper.kt | 3 +- .../matrix/ui/model/SpaceExtension.kt | 2 +- 15 files changed, 277 insertions(+), 115 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt index 29ceca14bf..b29da29a94 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.features.invite.api.SeenInvitesStore -import io.element.android.features.invite.api.seenSpaceIds import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.MatrixClient @@ -34,9 +33,9 @@ class HomeSpacesPresenter( .mediaPreviewConfigFlow .mapState { config -> config.hideInviteAvatar } }.collectAsState() - val spaceRooms by client.spaceService.spaceRooms.collectAsState(emptyList()) + val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList()) val seenSpaceInvites by remember { - seenInvitesStore.seenSpaceIds().map { it.toPersistentSet() } + seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } }.collectAsState(persistentSetOf()) fun handleEvents(event: HomeSpacesEvents) { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt index 6def46c7b3..39871fb905 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt @@ -7,6 +7,7 @@ package io.element.android.features.home.impl.spaces +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import kotlinx.collections.immutable.ImmutableSet @@ -14,7 +15,7 @@ import kotlinx.collections.immutable.ImmutableSet data class HomeSpacesState( val space: CurrentSpace, val spaceRooms: List, - val seenSpaceInvites: ImmutableSet, + val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, val eventSink: (HomeSpacesEvents) -> Unit, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt index 466f515b26..1aedc0ef19 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt @@ -8,7 +8,7 @@ package io.element.android.features.home.impl.spaces import androidx.compose.ui.tooling.preview.PreviewParameterProvider -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 kotlinx.collections.immutable.toImmutableSet @@ -18,12 +18,12 @@ open class HomeSpacesStateProvider : PreviewParameterProvider { aHomeSpacesState( spaceRooms = SpaceRoomProvider().values.toList(), seenSpaceInvites = setOf( - SpaceId("!spaceId3:example.com"), + RoomId("!spaceId3:example.com"), ), ), aHomeSpacesState( space = CurrentSpace.Space( - spaceRoom = aSpaceRooms(spaceId = SpaceId("!mySpace:example.com")) + spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com")) ), spaceRooms = aListOfSpaceRooms(), ), @@ -33,7 +33,7 @@ open class HomeSpacesStateProvider : PreviewParameterProvider { internal fun aHomeSpacesState( space: CurrentSpace = CurrentSpace.Root, spaceRooms: List = aListOfSpaceRooms(), - seenSpaceInvites: Set = emptySet(), + seenSpaceInvites: Set = emptySet(), hideInvitesAvatar: Boolean = false, eventSink: (HomeSpacesEvents) -> Unit = {}, ) = HomeSpacesState( @@ -46,8 +46,8 @@ internal fun aHomeSpacesState( fun aListOfSpaceRooms(): List { return listOf( - aSpaceRooms(spaceId = SpaceId("!spaceId0:example.com")), - aSpaceRooms(spaceId = SpaceId("!spaceId1:example.com")), - aSpaceRooms(spaceId = SpaceId("!spaceId2:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId0:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId1:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId2:example.com")), ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index be6639e94b..ae1cf09751 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView @@ -24,7 +25,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun HomeSpacesView( state: HomeSpacesState, - onSpaceClick: (SpaceId) -> Unit, + onSpaceClick: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier) { @@ -52,14 +53,14 @@ fun HomeSpacesView( } } state.spaceRooms.forEach { - item(it.spaceId) { + item(it.roomId) { val isInvitation = it.state == CurrentUserMembership.INVITED HomeSpaceItemView( spaceRoom = it, - showUnreadIndicator = isInvitation && it.spaceId !in state.seenSpaceInvites, + showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { - onSpaceClick(it.spaceId) + onSpaceClick(it.roomId) } ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt index 29c18c78a0..88c3c5799a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt @@ -9,7 +9,7 @@ package io.element.android.features.home.impl.spaces import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.join.JoinRule @@ -18,42 +18,42 @@ import io.element.android.libraries.matrix.api.user.MatrixUser class SpaceRoomProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( - aSpaceRooms(), - aSpaceRooms( + aSpaceRoom(), + aSpaceRoom( numJoinedMembers = 5, childrenCount = 10, worldReadable = true, - spaceId = SpaceId("!spaceId0:example.com"), + roomId = RoomId("!spaceId0:example.com"), ), - aSpaceRooms( + aSpaceRoom( numJoinedMembers = 5, childrenCount = 10, worldReadable = true, avatarUrl = "anUrl", - spaceId = SpaceId("!spaceId1:example.com"), + roomId = RoomId("!spaceId1:example.com"), ), - aSpaceRooms( + aSpaceRoom( name = null, numJoinedMembers = 5, childrenCount = 10, worldReadable = true, avatarUrl = "anUrl", - spaceId = SpaceId("!spaceId2:example.com"), + roomId = RoomId("!spaceId2:example.com"), state = CurrentUserMembership.INVITED, ), - aSpaceRooms( + aSpaceRoom( name = null, numJoinedMembers = 5, childrenCount = 10, worldReadable = true, avatarUrl = "anUrl", - spaceId = SpaceId("!spaceId3:example.com"), + roomId = RoomId("!spaceId3:example.com"), state = CurrentUserMembership.INVITED, ), ) } -fun aSpaceRooms( +fun aSpaceRoom( name: String? = "Space name", avatarUrl: String? = null, canonicalAlias: RoomAlias? = null, @@ -62,7 +62,7 @@ fun aSpaceRooms( heroes: List = emptyList(), joinRule: JoinRule? = null, numJoinedMembers: Int = 0, - spaceId: SpaceId = SpaceId("!spaceId:example.com"), + roomId: RoomId = RoomId("!roomId:example.com"), roomType: RoomType = RoomType.Space, state: CurrentUserMembership? = null, topic: String? = null, @@ -76,7 +76,7 @@ fun aSpaceRooms( heroes = heroes, joinRule = joinRule, numJoinedMembers = numJoinedMembers, - spaceId = spaceId, + roomId = roomId, roomType = roomType, state = state, topic = topic, diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt index 6c609e3810..283deb78fb 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt @@ -38,9 +38,3 @@ interface SeenInvitesStore { */ suspend fun clear() } - -fun SeenInvitesStore.seenSpaceIds(): Flow> { - return seenRoomIds().map { roomIds -> - roomIds.map { it.toSpaceId() }.toSet() - } -} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt index ce697089c7..102e594ea7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt @@ -8,7 +8,7 @@ package io.element.android.libraries.matrix.api.spaces 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.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.join.JoinRule @@ -23,7 +23,7 @@ data class SpaceRoom( val heroes: List, val joinRule: JoinRule?, val numJoinedMembers: Int, - val spaceId: SpaceId, + val roomId: RoomId, val roomType: RoomType, val state: CurrentUserMembership?, val topic: String?, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt new file mode 100644 index 0000000000..a1f79cdb0b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -0,0 +1,23 @@ +/* + * 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.api.spaces + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface SpaceRoomList { + sealed interface PaginationStatus { + data object Loading : PaginationStatus + data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus + } + + val spaceRoomsFlow: Flow> + val paginationStatusFlow: StateFlow + suspend fun paginate(): Result +} + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 58494502f7..917369a6a7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -7,9 +7,12 @@ package io.element.android.libraries.matrix.api.spaces +import io.element.android.libraries.matrix.api.core.SpaceId import kotlinx.coroutines.flow.SharedFlow interface SpaceService { - val spaceRooms: SharedFlow> + val spaceRoomsFlow: SharedFlow> suspend fun joinedSpaces(): Result> + + suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt new file mode 100644 index 0000000000..efe405714e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -0,0 +1,56 @@ +/* + * 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.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList + +class RustSpaceRoomList( + private val inner: InnerSpaceRoomList, + sessionCoroutineScope: CoroutineScope, + spaceRoomMapper: SpaceRoomMapper, +) : SpaceRoomList { + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + override val paginationStatusFlow = MutableStateFlow(inner.paginationState().into()) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor(spaceRoomsFlow, spaceRoomMapper) + + init { + inner.paginationStateFlow() + .onEach { paginationStatus -> + paginationStatusFlow.emit(paginationStatus.into()) + } + .launchIn(sessionCoroutineScope) + + inner.spaceListUpdateFlow() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) + } + .launchIn(sessionCoroutineScope) + } + + override suspend fun paginate(): Result { + return runCatchingExceptions { + inner.paginate() + } + } + + private fun SpaceRoomListPaginationState.into(): SpaceRoomList.PaginationStatus { + return when (this) { + is SpaceRoomListPaginationState.Idle -> SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = !endReached) + SpaceRoomListPaginationState.Loading -> SpaceRoomList.PaginationStatus.Loading + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index b059d476bc..4d26040021 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -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.SpaceId 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,49 @@ class RustSpaceService( private val sessionCoroutineScope: CoroutineScope, private val sessionDispatcher: CoroutineDispatcher, ) : SpaceService { - private val mapper = SpaceRoomMapper() - private val mutex = Mutex() - - override val spaceRooms = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + private val spaceRoomMapper = SpaceRoomMapper() + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor(spaceRoomsFlow, spaceRoomMapper) override suspend fun joinedSpaces(): Result> = withContext(sessionDispatcher) { runCatchingExceptions { innerSpaceService.joinedSpaces() .map { - it.let(mapper::map) + it.let(spaceRoomMapper::map) } } } - // override suspend fun spaceRoomList(spaceId: SpaceId): Result> = withContext(sessionDispatcher) { - // runCatchingExceptions { - // innerSpaceService.spaceRoomList(spaceId.value) - // } - // } + override suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList { + val innerSpaceRoomList = innerSpaceService.spaceRoomList(spaceId.value) + return RustSpaceRoomList( + inner = innerSpaceRoomList, + sessionCoroutineScope = sessionCoroutineScope, + spaceRoomMapper = spaceRoomMapper + ) + } init { innerSpaceService - .spaceDiffFlow() - .onEach { - handeUpdate(it) + .spaceListUpdate() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) } .launchIn(sessionCoroutineScope) } - - private suspend fun handeUpdate(spaceListUpdates: List) { - 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.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> = +internal fun SpaceServiceInterface.spaceListUpdate(): Flow> = callbackFlow { val listener = object : SpaceServiceJoinedSpacesListener { override fun onUpdate(roomUpdates: List) { 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 { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt new file mode 100644 index 0000000000..a1f8584299 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt @@ -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>, + private val mapper: SpaceRoomMapper, +) { + private val mutex = Mutex() + + suspend fun postUpdates(updates: List) { + 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.() -> Unit) = + mutex.withLock { + val spaceRooms = if (spaceRoomsFlow.replayCache.isNotEmpty()) { + spaceRoomsFlow.first().toMutableList() + } else { + mutableListOf() + } + block(spaceRooms) + spaceRoomsFlow.tryEmit(spaceRooms) + } + + private fun MutableList.applyUpdate(update: SpaceListUpdate) { + when (update) { + is SpaceListUpdate.Append -> { + val newSpaces = update.values.map { it -> + it.let(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() + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt new file mode 100644 index 0000000000..4976f37117 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt @@ -0,0 +1,54 @@ +/* + * 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 = callbackFlow { + val listener = object : SpaceRoomListPaginationStateListener { + override fun onUpdate(paginationState: SpaceRoomListPaginationState) { + trySend(paginationState) + } + } + val result = subscribeToPaginationStateUpdates(listener) + awaitClose { + result.cancelAndDestroy() + } +}.catch { + Timber.d(it, "paginationStateFlow() failed") +}.buffer(Channel.UNLIMITED) + +internal fun SpaceRoomListInterface.spaceListUpdateFlow(): Flow> = + callbackFlow { + val listener = object : SpaceRoomListEntriesListener { + override fun onUpdate(rooms: List) { + 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) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt index 5622ee2c8c..ade5063515 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt @@ -9,6 +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.RoomId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.impl.room.join.map @@ -26,7 +27,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, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt index afe03b6877..e4b056fdea 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt @@ -12,7 +12,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.spaces.SpaceRoom fun SpaceRoom.getAvatarData(size: AvatarSize) = AvatarData( - id = spaceId.value, + id = roomId.value, name = name, url = avatarUrl, size = size, From 5d8ca6590e74d01f42c0fb8137d999e68a215935 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 5 Sep 2025 16:07:37 +0200 Subject: [PATCH 02/17] feature (space) : extract SpaceRoomItemView --- .../home/impl/spaces/HomeSpacesView.kt | 7 +- .../libraries/matrix/api/spaces/SpaceRoom.kt | 4 +- .../matrix/ui/components/SpaceRoomItemView.kt | 140 +++++++++++------- 3 files changed, 96 insertions(+), 55 deletions(-) rename features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt => libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt (66%) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index ae1cf09751..cb93b297be 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -15,10 +15,10 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView import io.element.android.libraries.matrix.ui.components.SpaceHeaderView +import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.model.getAvatarData import kotlinx.collections.immutable.toImmutableList @@ -55,12 +55,15 @@ fun HomeSpacesView( state.spaceRooms.forEach { item(it.roomId) { val isInvitation = it.state == CurrentUserMembership.INVITED - HomeSpaceItemView( + SpaceRoomItemView( spaceRoom = it, showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { onSpaceClick(it.roomId) + }, + onLongClick = { + } ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt index 102e594ea7..d4e1d57826 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt @@ -28,4 +28,6 @@ data class SpaceRoom( val state: CurrentUserMembership?, val topic: String?, val worldReadable: Boolean, -) +) { + val isSpace = roomType == RoomType.Space +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt similarity index 66% rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt index 461c550d8e..be540d486d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.home.impl.spaces +package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -22,88 +22,66 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings @Composable -internal fun HomeSpaceItemView( +fun SpaceRoomItemView( spaceRoom: SpaceRoom, showUnreadIndicator: Boolean, hideAvatars: Boolean, onClick: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier, ) { - SpaceScaffoldRow( + SpaceRoomItemScaffold( modifier = modifier, - spaceRoom = spaceRoom, - onClick = onClick, + avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem), + isSpace = spaceRoom.isSpace, hideAvatars = hideAvatars, - onLongClick = { }, + onClick = onClick, + onLongClick = onLongClick, ) { NameAndIndicatorRow( name = spaceRoom.name, - showIndicator = showUnreadIndicator, + showIndicator = showUnreadIndicator ) Spacer(modifier = Modifier.height(1.dp)) - if (!spaceRoom.worldReadable) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier - .size(16.dp) - .padding(end = 4.dp), - imageVector = CompoundIcons.LockSolid(), - contentDescription = null, - tint = ElementTheme.colors.iconTertiary, - ) - Text( - modifier = Modifier.weight(1f), - style = ElementTheme.typography.fontBodyMdRegular, - text = stringResource(CommonStrings.common_private_space), - fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null }, - color = ElementTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(modifier = Modifier.height(1.dp)) - } - val spaceSummary = stringResource( - CommonStrings.screen_space_list_details, - pluralStringResource(CommonPlurals.common_rooms, spaceRoom.childrenCount, spaceRoom.childrenCount), - pluralStringResource(CommonPlurals.common_member_count, spaceRoom.numJoinedMembers, spaceRoom.numJoinedMembers), + SubtitleRow( + visibilityIcon = spaceRoom.visibilityIcon(), + subtitle = spaceRoom.subtitle() ) + Spacer(modifier = Modifier.height(1.dp)) Text( modifier = Modifier.weight(1f), style = ElementTheme.typography.fontBodyMdRegular, - text = spaceSummary, + text = spaceRoom.info(), fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null }, color = ElementTheme.colors.textSecondary, maxLines = 1, @@ -119,6 +97,37 @@ internal fun HomeSpaceItemView( } } +@Composable +private fun SubtitleRow( + visibilityIcon: ImageVector?, + subtitle: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (visibilityIcon != null) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(end = 4.dp), + imageVector = visibilityIcon, + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + Text( + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitle, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + @Composable private fun NameAndIndicatorRow( name: String?, @@ -148,8 +157,9 @@ private fun NameAndIndicatorRow( } @Composable -private fun SpaceScaffoldRow( - spaceRoom: SpaceRoom, +private fun SpaceRoomItemScaffold( + avatarData: AvatarData, + isSpace: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, hideAvatars: Boolean, @@ -173,8 +183,8 @@ private fun SpaceScaffoldRow( .height(IntrinsicSize.Min), ) { Avatar( - avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem), - avatarType = AvatarType.Space(), + avatarData = avatarData, + avatarType = if (isSpace) AvatarType.Space() else AvatarType.Room(), hideImage = hideAvatars, ) Spacer(modifier = Modifier.width(16.dp)) @@ -185,13 +195,39 @@ private fun SpaceScaffoldRow( } } -@PreviewsDayNight @Composable -internal fun HomeSpaceItemViewPreview(@PreviewParameter(SpaceRoomProvider::class) spaceRoom: SpaceRoom) = ElementPreview { - HomeSpaceItemView( - spaceRoom = spaceRoom, - showUnreadIndicator = false, - hideAvatars = true, - onClick = {}, - ) +@ReadOnlyComposable +private fun SpaceRoom.subtitle(): String { + return if (isSpace) { + if (joinRule == JoinRule.Public) { + stringResource(CommonStrings.common_public_space) + } else { + stringResource(CommonStrings.common_private_space) + } + } else { + pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers) + } +} + +@Composable +@ReadOnlyComposable +private fun SpaceRoom.info(): String { + return if (isSpace) { + stringResource( + CommonStrings.screen_space_list_details, + pluralStringResource(CommonPlurals.common_rooms, childrenCount, childrenCount), + pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers), + ) + } else { + topic.orEmpty() + } +} + +@Composable +private fun SpaceRoom.visibilityIcon(): ImageVector? { + return if (joinRule == JoinRule.Public) { + CompoundIcons.Public() + } else { + CompoundIcons.LockSolid() + } } From 4048bb7fb6df69841b0955cdaa589a6bea8211f1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 8 Sep 2025 15:41:27 +0200 Subject: [PATCH 03/17] feature(spaces) : start introducing SpaceScreen --- features/space/api/build.gradle.kts | 18 ++ .../features/space/api/SpaceEntryPoint.kt | 38 ++++ features/space/impl/build.gradle.kts | 57 ++++++ .../space/impl/DefaultSpaceEntryPoint.kt | 39 ++++ .../features/space/impl/SpaceEvents.kt | 12 ++ .../android/features/space/impl/SpaceNode.kt | 39 ++++ .../features/space/impl/SpacePresenter.kt | 55 ++++++ .../android/features/space/impl/SpaceState.kt | 21 +++ .../features/space/impl/SpaceStateProvider.kt | 25 +++ .../android/features/space/impl/SpaceView.kt | 177 ++++++++++++++++++ .../matrix/api/spaces/SpaceService.kt | 4 +- .../matrix/impl/spaces/RustSpaceService.kt | 5 +- 12 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 features/space/api/build.gradle.kts create mode 100644 features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt create mode 100644 features/space/impl/build.gradle.kts create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt diff --git a/features/space/api/build.gradle.kts b/features/space/api/build.gradle.kts new file mode 100644 index 0000000000..b9a7f3226e --- /dev/null +++ b/features/space/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.space.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt new file mode 100644 index 0000000000..813450d7cb --- /dev/null +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -0,0 +1,38 @@ +/* + * 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.features.space.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.spaces.SpaceRoom + +interface SpaceEntryPoint : FeatureEntryPoint { + fun nodeBuilder( + parentNode: Node, + buildContext: BuildContext, + ): NodeBuilder + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + sealed interface Params : Plugin { + data class Id(val roomId: RoomId) : Params + data class Full(val spaceRoom: SpaceRoom) : Params + } + + interface Callback : Plugin { + fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List) + } +} diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts new file mode 100644 index 0000000000..5a397fa269 --- /dev/null +++ b/features/space/impl/build.gradle.kts @@ -0,0 +1,57 @@ +import extension.ComponentMergingStrategy +import extension.setupAnvil + +/* + * Copyright 2022-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. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.space.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupAnvil(componentMergingStrategy = ComponentMergingStrategy.KSP) + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.deeplink.api) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(projects.libraries.featureflag.api) + implementation(projects.features.invite.api) + api(projects.features.space.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.features.invite.test) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt new file mode 100644 index 0000000000..9a0f10a15b --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -0,0 +1,39 @@ +/* + * 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.features.space.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultSpaceEntryPoint @Inject constructor() : SpaceEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder { + val plugins = mutableSetOf() + return object : SpaceEntryPoint.NodeBuilder { + override fun params(params: SpaceEntryPoint.Params): SpaceEntryPoint.NodeBuilder { + plugins.add(params) + return this + } + + override fun callback(callback: SpaceEntryPoint.Callback): SpaceEntryPoint.NodeBuilder { + plugins.add(callback) + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins = plugins.toList()) + } + } + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt new file mode 100644 index 0000000000..16a521f6dd --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt @@ -0,0 +1,12 @@ +/* + * 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.features.space.impl + +sealed interface SpaceEvents { + +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt new file mode 100644 index 0000000000..950cfe17d7 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -0,0 +1,39 @@ +/* + * 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.features.space.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class SpaceNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SpacePresenter, +) : Node(buildContext, plugins = plugins) { + + val params = plugins.filterIsInstance().single() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SpaceView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt new file mode 100644 index 0000000000..b4a764b409 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -0,0 +1,55 @@ +/* + * 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.features.space.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class SpacePresenter @Inject constructor( + private val client: MatrixClient, + private val seenInvitesStore: SeenInvitesStore, +) : Presenter { + + @Composable + override fun present(): SpaceState { + val hideInvitesAvatar by remember { + client + .mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() + val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList()) + val seenSpaceInvites by remember { + seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } + }.collectAsState(persistentSetOf()) + + fun handleEvents(event: SpaceEvents) { + //when (event) { } + } + + return SpaceState( + parentSpace = null, + children = spaceRooms.toPersistentList(), + seenSpaceInvites = seenSpaceInvites, + hideInvitesAvatar = hideInvitesAvatar, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt new file mode 100644 index 0000000000..ad3913af3d --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt @@ -0,0 +1,21 @@ +/* + * 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.features.space.impl + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class SpaceState( + val parentSpace: SpaceRoom?, + val children: ImmutableList, + val seenSpaceInvites: ImmutableSet, + val hideInvitesAvatar: Boolean, + val eventSink: (SpaceEvents) -> Unit +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt new file mode 100644 index 0000000000..b5cb3e9546 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt @@ -0,0 +1,25 @@ +/* + * 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.features.space.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlinx.collections.immutable.persistentListOf + +open class SpaceStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceState(), + // Add other states here + ) +} + +fun aSpaceState() = SpaceState( + parentSpace = null, + children = persistentListOf(), + eventSink = {} +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt new file mode 100644 index 0000000000..d1774d98bf --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -0,0 +1,177 @@ +/* + * 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.features.space.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +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.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView +import io.element.android.libraries.matrix.ui.components.SpaceHeaderView +import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun SpaceView( + state: SpaceState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets.statusBars, + topBar = { + SpaceViewTopBar(spaceRoom = null, onBackClick = onBackClick) + }, + content = { padding -> + Box( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + SpaceViewContent(state) + } + }, + ) +} + + +@Composable +private fun SpaceViewContent( + state: SpaceState, + modifier: Modifier = Modifier, +){ + LazyColumn(modifier) { + val parentSpace = state.parentSpace + if (parentSpace != null) { + item { + SpaceHeaderView( + avatarData = parentSpace.getAvatarData(AvatarSize.SpaceHeader), + name = parentSpace.name, + topic = parentSpace.topic, + joinRule = parentSpace.joinRule, + heroes = parentSpace.heroes.toImmutableList(), + numberOfMembers = parentSpace.numJoinedMembers, + numberOfRooms = parentSpace.childrenCount, + ) + } + } + state.children.forEach { + item(it.roomId) { + val isInvitation = it.state == CurrentUserMembership.INVITED + SpaceRoomItemView( + spaceRoom = it, + showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, + hideAvatars = isInvitation && state.hideInvitesAvatar, + onClick = { + + }, + onLongClick = { + + } + ) + } + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpaceViewTopBar( + spaceRoom: SpaceRoom?, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + if (spaceRoom != null) { + SpaceAvatarAndNameRow( + name = spaceRoom.name, + avatarData = spaceRoom.getAvatarData(AvatarSize.TimelineRoom), + ) + } + }, + actions = { + }, + windowInsets = WindowInsets(0.dp) + ) +} + +@Composable +private fun SpaceAvatarAndNameRow( + name: String?, + avatarData: AvatarData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(), + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + text = name ?: stringResource(CommonStrings.common_no_room_name), + style = ElementTheme.typography.fontBodyLgMedium, + fontStyle = FontStyle.Italic.takeIf { name == null }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceViewPreview( + @PreviewParameter(SpaceStateProvider::class) state: SpaceState +) = ElementPreview { + SpaceView( + state = state, + onBackClick = {}, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 917369a6a7..22f78bf780 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -7,12 +7,12 @@ package io.element.android.libraries.matrix.api.spaces -import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.SharedFlow interface SpaceService { val spaceRoomsFlow: SharedFlow> suspend fun joinedSpaces(): Result> - suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList + suspend fun spaceRoomList(id: RoomId): SpaceRoomList } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 4d26040021..da4f62ad0c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -8,6 +8,7 @@ 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.core.SpaceId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList @@ -50,8 +51,8 @@ class RustSpaceService( } } - override suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList { - val innerSpaceRoomList = innerSpaceService.spaceRoomList(spaceId.value) + override suspend fun spaceRoomList(id: RoomId): SpaceRoomList { + val innerSpaceRoomList = innerSpaceService.spaceRoomList(id.value) return RustSpaceRoomList( inner = innerSpaceRoomList, sessionCoroutineScope = sessionCoroutineScope, From b45a4c3b2cec710eb5fab79a88960136275818ed Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 8 Sep 2025 21:57:25 +0200 Subject: [PATCH 04/17] feature (space) : iterate on space list (and space screen) --- .../android/appnav/room/RoomFlowNode.kt | 26 ++++++------ .../android/features/home/impl/HomeView.kt | 2 +- .../home/impl/spaces/HomeSpacesView.kt | 12 +++--- .../features/space/api/SpaceEntryPoint.kt | 7 ++++ .../android/features/space/impl/SpaceNode.kt | 3 +- .../features/space/impl/SpacePresenter.kt | 22 +++++++--- .../android/features/space/impl/SpaceView.kt | 1 - .../matrix/api/spaces/SpaceService.kt | 2 +- .../matrix/impl/spaces/RustSpaceRoomList.kt | 40 ++++++++++++------- .../matrix/impl/spaces/RustSpaceService.kt | 5 +-- .../impl/spaces/SpaceRoomListExtensions.kt | 3 ++ 11 files changed, 79 insertions(+), 44 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 12d9df78cf..c6381d4c7d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -30,7 +30,9 @@ import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs @@ -43,6 +45,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias @@ -72,6 +75,7 @@ class RoomFlowNode( private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val syncService: SyncService, private val membershipObserver: RoomMembershipObserver, + private val spaceEntryPoint: SpaceEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Loading, @@ -106,6 +110,9 @@ class RoomFlowNode( @Parcelize data class JoinedRoom(val roomId: RoomId) : NavTarget + + @Parcelize + data class Space(val spaceId: RoomId) : NavTarget } override fun onBuilt() { @@ -146,17 +153,7 @@ class RoomFlowNode( when (membership) { CurrentUserMembership.JOINED -> { if (isSpace) { - // It should not happen, but probably due to an issue in the sliding sync, - // we can have a space here in case the space has just been joined. - // So navigate to the JoinRoom target for now, which will - // handle the space not supported screen - backstack.newRoot( - NavTarget.JoinRoom( - roomId = roomId, - serverNames = serverNames, - trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite, - ) - ) + backstack.newRoot(NavTarget.Space(spaceId = roomId)) } else { backstack.newRoot(NavTarget.JoinedRoom(roomId)) } @@ -194,7 +191,7 @@ class RoomFlowNode( ) } } - val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias) + val params = Params(navTarget.roomAlias) roomAliasResolverEntryPoint.nodeBuilder(this, buildContext) .callback(callback) .params(params) @@ -218,6 +215,11 @@ class RoomFlowNode( ) createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) } + is NavTarget.Space -> { + spaceEntryPoint.nodeBuilder(this, buildContext) + .params(SpaceEntryPoint.Params.Id(navTarget.spaceId)) + .build() + } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6d531504ab..37727712fb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -269,7 +269,7 @@ private fun HomeScaffold( .hazeSource(state = hazeState), state = state.homeSpacesState, onSpaceClick = { spaceId -> - // TODO + onRoomClick(spaceId) } ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index cb93b297be..51dac2e0a3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -52,15 +52,15 @@ fun HomeSpacesView( ) } } - state.spaceRooms.forEach { - item(it.roomId) { - val isInvitation = it.state == CurrentUserMembership.INVITED + state.spaceRooms.forEach { spaceRoom -> + item(spaceRoom.roomId) { + val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED SpaceRoomItemView( - spaceRoom = it, - showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, + spaceRoom = spaceRoom, + showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { - onSpaceClick(it.roomId) + onSpaceClick(spaceRoom.roomId) }, onLongClick = { diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt index 813450d7cb..6a7002af2f 100644 --- a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -30,6 +30,13 @@ interface SpaceEntryPoint : FeatureEntryPoint { sealed interface Params : Plugin { data class Id(val roomId: RoomId) : Params data class Full(val spaceRoom: SpaceRoom) : Params + + fun roomId(): RoomId { + return when (this) { + is Id -> roomId + is Full -> spaceRoom.roomId + } + } } interface Callback : Plugin { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt index 950cfe17d7..870b9b139c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -22,10 +22,11 @@ import io.element.android.libraries.di.SessionScope class SpaceNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: SpacePresenter, + private val presenterFactory: SpacePresenter.Factory, ) : Node(buildContext, plugins = plugins) { val params = plugins.filterIsInstance().single() + private val presenter = presenterFactory.create(params) @Composable override fun View(modifier: Modifier) { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt index b4a764b409..7e1c12ca87 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -11,22 +11,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.Inject import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.MatrixClient -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.map -import javax.inject.Inject -class SpacePresenter @Inject constructor( +@Inject +class SpacePresenter( + @Assisted private val params: SpaceEntryPoint.Params, private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(params: SpaceEntryPoint.Params): SpacePresenter + } + + private val spaceRoomList = client.spaceService.spaceRoomList(params.roomId()) + @Composable override fun present(): SpaceState { val hideInvitesAvatar by remember { @@ -35,18 +46,19 @@ class SpacePresenter @Inject constructor( .mediaPreviewConfigFlow .mapState { config -> config.hideInviteAvatar } }.collectAsState() - val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList()) val seenSpaceInvites by remember { seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } }.collectAsState(persistentSetOf()) + val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList()) + fun handleEvents(event: SpaceEvents) { //when (event) { } } return SpaceState( parentSpace = null, - children = spaceRooms.toPersistentList(), + children = children.toPersistentList(), seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, eventSink = ::handleEvents, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt index d1774d98bf..d7d03974da 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -132,7 +132,6 @@ private fun SpaceViewTopBar( }, actions = { }, - windowInsets = WindowInsets(0.dp) ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 22f78bf780..b4572ad0bb 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -14,5 +14,5 @@ interface SpaceService { val spaceRoomsFlow: SharedFlow> suspend fun joinedSpaces(): Result> - suspend fun spaceRoomList(id: RoomId): SpaceRoomList + fun spaceRoomList(id: RoomId): SpaceRoomList } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt index efe405714e..9691f63c12 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -10,40 +10,52 @@ package io.element.android.libraries.matrix.impl.spaces import io.element.android.libraries.core.extensions.runCatchingExceptions 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.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn +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 inner: InnerSpaceRoomList, + private val innerProvider: suspend () -> InnerSpaceRoomList, sessionCoroutineScope: CoroutineScope, spaceRoomMapper: SpaceRoomMapper, ) : SpaceRoomList { + + private val inner = CompletableDeferred() override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = Int.MAX_VALUE) - override val paginationStatusFlow = MutableStateFlow(inner.paginationState().into()) + override val paginationStatusFlow: MutableStateFlow = + MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) private val spaceListUpdateProcessor = SpaceListUpdateProcessor(spaceRoomsFlow, spaceRoomMapper) init { - inner.paginationStateFlow() - .onEach { paginationStatus -> - paginationStatusFlow.emit(paginationStatus.into()) - } - .launchIn(sessionCoroutineScope) + sessionCoroutineScope.launch { + inner.complete(innerProvider()) + } + sessionCoroutineScope.launch { + inner.await().paginationStateFlow() + .onEach { paginationStatus -> + paginationStatusFlow.emit(paginationStatus.into()) + } + .collect() + } - inner.spaceListUpdateFlow() - .onEach { updates -> - spaceListUpdateProcessor.postUpdates(updates) - } - .launchIn(sessionCoroutineScope) + sessionCoroutineScope.launch { + inner.await().spaceListUpdateFlow() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) + } + .collect() + } } override suspend fun paginate(): Result { return runCatchingExceptions { - inner.paginate() + inner.await().paginate() } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index da4f62ad0c..3e8fd77843 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -51,10 +51,9 @@ class RustSpaceService( } } - override suspend fun spaceRoomList(id: RoomId): SpaceRoomList { - val innerSpaceRoomList = innerSpaceService.spaceRoomList(id.value) + override fun spaceRoomList(id: RoomId): SpaceRoomList { return RustSpaceRoomList( - inner = innerSpaceRoomList, + innerProvider = { innerSpaceService.spaceRoomList(id.value) }, sessionCoroutineScope = sessionCoroutineScope, spaceRoomMapper = spaceRoomMapper ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt index 4976f37117..3d1a002e4d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt @@ -28,6 +28,9 @@ internal fun SpaceRoomListInterface.paginationStateFlow(): Flow Date: Wed, 10 Sep 2025 10:48:34 +0200 Subject: [PATCH 05/17] feature (space) : add space cache and navigation to sub space/room --- .../android/appnav/LoggedInFlowNode.kt | 10 ++- .../android/appnav/room/RoomFlowNode.kt | 5 +- features/home/impl/build.gradle.kts | 1 + .../impl/spaces/HomeSpacesStateProvider.kt | 1 + .../home/impl/spaces/SpaceRoomProvider.kt | 35 +------- .../features/space/api/SpaceEntryPoint.kt | 20 ++--- features/space/impl/build.gradle.kts | 1 + .../space/impl/DefaultSpaceEntryPoint.kt | 4 +- .../features/space/impl/SpaceEvents.kt | 2 +- .../android/features/space/impl/SpaceNode.kt | 10 ++- .../features/space/impl/SpacePresenter.kt | 39 +++++++-- .../android/features/space/impl/SpaceState.kt | 3 +- .../features/space/impl/SpaceStateProvider.kt | 43 +++++++++- .../android/features/space/impl/SpaceView.kt | 83 +++++++++++++------ .../matrix/api/spaces/SpaceRoomList.kt | 2 + .../matrix/impl/spaces/RustSpaceRoomList.kt | 15 +++- .../matrix/impl/spaces/RustSpaceService.kt | 11 ++- .../impl/spaces/SpaceListUpdateProcessor.kt | 4 +- .../matrix/impl/spaces/SpaceRoomCache.kt | 44 ++++++++++ .../previewutils/room/SpaceRoomFixture.kt | 46 ++++++++++ 20 files changed, 278 insertions(+), 101 deletions(-) create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt create mode 100644 libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 2d08d5f6f5..97fff8ee5c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -60,6 +60,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint @@ -334,7 +335,7 @@ class LoggedInFlowNode( .build() } is NavTarget.Room -> { - val callback = object : JoinedRoomLoadedFlowNode.Callback { + val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback { override fun onOpenRoom(roomId: RoomId, serverNames: List) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) } @@ -373,6 +374,11 @@ class LoggedInFlowNode( backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) } } + val spaceCallback = object : SpaceEntryPoint.Callback { + override fun onOpenRoom(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) + } + } val inputs = RoomFlowNode.Inputs( roomIdOrAlias = navTarget.roomIdOrAlias, roomDescription = Optional.ofNullable(navTarget.roomDescription), @@ -380,7 +386,7 @@ class LoggedInFlowNode( trigger = Optional.ofNullable(navTarget.trigger), initialElement = navTarget.initialElement ) - createNode(buildContext, plugins = listOf(inputs, callback)) + createNode(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback)) } is NavTarget.UserProfile -> { val callback = object : UserProfileEntryPoint.Callback { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index c6381d4c7d..f2df6ee8c9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -45,7 +45,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias @@ -216,8 +215,10 @@ class RoomFlowNode( createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) } is NavTarget.Space -> { + val spaceCallback = plugins().single() spaceEntryPoint.nodeBuilder(this, buildContext) - .params(SpaceEntryPoint.Params.Id(navTarget.spaceId)) + .inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId)) + .callback(spaceCallback) .build() } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 57fefaf413..f0ebe6c02d 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.haze.materials) implementation(projects.features.reportroom.api) implementation(projects.features.changeroommemberroles.api) + implementation(projects.libraries.previewutils) api(projects.features.home.api) testImplementation(libs.androidx.compose.ui.test.junit) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt index 1aedc0ef19..921c340886 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.home.impl.spaces import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom import kotlinx.collections.immutable.toImmutableSet open class HomeSpacesStateProvider : PreviewParameterProvider { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt index 88c3c5799a..474e08293a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt @@ -8,13 +8,10 @@ package io.element.android.features.home.impl.spaces import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.CurrentUserMembership -import io.element.android.libraries.matrix.api.room.RoomType -import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.spaces.SpaceRoom -import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.previewutils.room.aSpaceRoom class SpaceRoomProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( @@ -52,33 +49,3 @@ class SpaceRoomProvider : PreviewParameterProvider { ), ) } - -fun aSpaceRoom( - name: String? = "Space name", - avatarUrl: String? = null, - canonicalAlias: RoomAlias? = null, - childrenCount: Int = 0, - guestCanJoin: Boolean = false, - heroes: List = emptyList(), - joinRule: JoinRule? = null, - numJoinedMembers: Int = 0, - roomId: RoomId = RoomId("!roomId:example.com"), - roomType: RoomType = RoomType.Space, - state: CurrentUserMembership? = null, - topic: String? = null, - worldReadable: Boolean = false, -) = SpaceRoom( - name = name, - avatarUrl = avatarUrl, - canonicalAlias = canonicalAlias, - childrenCount = childrenCount, - guestCanJoin = guestCanJoin, - heroes = heroes, - joinRule = joinRule, - numJoinedMembers = numJoinedMembers, - roomId = roomId, - roomType = roomType, - state = state, - topic = topic, - worldReadable = worldReadable -) diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt index 6a7002af2f..cc298de601 100644 --- a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -12,8 +12,6 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias -import io.element.android.libraries.matrix.api.spaces.SpaceRoom interface SpaceEntryPoint : FeatureEntryPoint { fun nodeBuilder( @@ -22,24 +20,16 @@ interface SpaceEntryPoint : FeatureEntryPoint { ): NodeBuilder interface NodeBuilder { - fun params(params: Params): NodeBuilder + fun inputs(inputs: Inputs): NodeBuilder fun callback(callback: Callback): NodeBuilder fun build(): Node } - sealed interface Params : Plugin { - data class Id(val roomId: RoomId) : Params - data class Full(val spaceRoom: SpaceRoom) : Params - - fun roomId(): RoomId { - return when (this) { - is Id -> roomId - is Full -> spaceRoom.roomId - } - } - } + data class Inputs( + val roomId: RoomId + ) : Plugin interface Callback : Plugin { - fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List) + fun onOpenRoom(roomId: RoomId) } } diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts index 5a397fa269..5731b4a819 100644 --- a/features/space/impl/build.gradle.kts +++ b/features/space/impl/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.coil.compose) implementation(projects.libraries.featureflag.api) implementation(projects.features.invite.api) + implementation(projects.libraries.previewutils) api(projects.features.space.api) testImplementation(libs.test.junit) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt index 9a0f10a15b..22988a6964 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -21,8 +21,8 @@ class DefaultSpaceEntryPoint @Inject constructor() : SpaceEntryPoint { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder { val plugins = mutableSetOf() return object : SpaceEntryPoint.NodeBuilder { - override fun params(params: SpaceEntryPoint.Params): SpaceEntryPoint.NodeBuilder { - plugins.add(params) + override fun inputs(inputs: SpaceEntryPoint.Inputs): SpaceEntryPoint.NodeBuilder { + plugins.add(inputs) return this } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt index 16a521f6dd..848dac3ebc 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt @@ -8,5 +8,5 @@ package io.element.android.features.space.impl sealed interface SpaceEvents { - + data object LoadMore : SpaceEvents } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt index 870b9b139c..c89e7d0aad 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -22,11 +22,12 @@ import io.element.android.libraries.di.SessionScope class SpaceNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenterFactory: SpacePresenter.Factory, + presenterFactory: SpacePresenter.Factory, ) : Node(buildContext, plugins = plugins) { - val params = plugins.filterIsInstance().single() - private val presenter = presenterFactory.create(params) + val inputs = plugins.filterIsInstance().single() + val callback = plugins.filterIsInstance().single() + private val presenter = presenterFactory.create(inputs) @Composable override fun View(modifier: Modifier) { @@ -34,6 +35,9 @@ class SpaceNode @AssistedInject constructor( SpaceView( state = state, onBackClick = ::navigateUp, + onRoomClick = { roomId -> + callback.onOpenRoom(roomId) + }, modifier = modifier ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt index 7e1c12ca87..5efc207430 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -8,9 +8,11 @@ package io.element.android.features.space.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.Inject @@ -19,27 +21,35 @@ import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch @Inject class SpacePresenter( - @Assisted private val params: SpaceEntryPoint.Params, + @Assisted private val inputs: SpaceEntryPoint.Inputs, private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, ) : Presenter { @AssistedFactory interface Factory { - fun create(params: SpaceEntryPoint.Params): SpacePresenter + fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter } - private val spaceRoomList = client.spaceService.spaceRoomList(params.roomId()) + private val spaceRoomList = client.spaceService.spaceRoomList(inputs.roomId) @Composable override fun present(): SpaceState { + + LaunchedEffect(Unit) { + paginate() + } + val hideInvitesAvatar by remember { client .mediaPreviewService() @@ -50,18 +60,35 @@ class SpacePresenter( seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } }.collectAsState(persistentSetOf()) + val coroutineScope = rememberCoroutineScope() val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList()) + val hasMoreToLoad by remember { + spaceRoomList.paginationStatusFlow.mapState { status -> + when (status) { + is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad + SpaceRoomList.PaginationStatus.Loading -> true + } + } + }.collectAsState() + + val currentSpace by remember { spaceRoomList.currentSpaceFlow() }.collectAsState(null) fun handleEvents(event: SpaceEvents) { - //when (event) { } + when (event) { + SpaceEvents.LoadMore -> coroutineScope.paginate() + } } - return SpaceState( - parentSpace = null, + currentSpace = currentSpace, children = children.toPersistentList(), seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, + hasMoreToLoad = hasMoreToLoad, eventSink = ::handleEvents, ) } + + private fun CoroutineScope.paginate() = launch { + spaceRoomList.paginate() + } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt index ad3913af3d..ad822283ca 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt @@ -13,9 +13,10 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet data class SpaceState( - val parentSpace: SpaceRoom?, + val currentSpace: SpaceRoom?, val children: ImmutableList, val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, + val hasMoreToLoad: Boolean, val eventSink: (SpaceEvents) -> Unit ) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt index b5cb3e9546..36c2ab62b0 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt @@ -8,18 +8,53 @@ package io.element.android.features.space.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import kotlinx.collections.immutable.persistentListOf +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet open class SpaceStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aSpaceState(), + aSpaceState(hasMoreToLoad = true), + aSpaceState( + hasMoreToLoad = true, + children = aListOfSpaceRooms(), + ), + aSpaceState( + hasMoreToLoad = false, + children = aListOfSpaceRooms() + ) // Add other states here ) } -fun aSpaceState() = SpaceState( - parentSpace = null, - children = persistentListOf(), +fun aSpaceState( + parentSpace: SpaceRoom? = aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + roomId = RoomId("!spaceId0:example.com"), + ), + children: List = emptyList(), + seenSpaceInvites: Set = emptySet(), + hideInvitesAvatar: Boolean = false, + hasMoreToLoad: Boolean = false, +) = SpaceState( + currentSpace = parentSpace, + children = children.toImmutableList(), + seenSpaceInvites = seenSpaceInvites.toImmutableSet(), + hideInvitesAvatar = hideInvitesAvatar, + hasMoreToLoad = hasMoreToLoad, eventSink = {} ) + +private fun aListOfSpaceRooms(): List { + return listOf( + aSpaceRoom(roomId = RoomId("!spaceId0:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId1:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId2:example.com")), + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt index d7d03974da..b6077f4007 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -9,13 +9,15 @@ package io.element.android.features.space.impl import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -33,12 +35,13 @@ 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.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom -import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView import io.element.android.libraries.matrix.ui.components.SpaceHeaderView import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -49,56 +52,57 @@ import kotlinx.collections.immutable.toImmutableList fun SpaceView( state: SpaceState, onBackClick: () -> Unit, + onRoomClick: (roomId: RoomId) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, - contentWindowInsets = WindowInsets.statusBars, topBar = { SpaceViewTopBar(spaceRoom = null, onBackClick = onBackClick) }, content = { padding -> Box( - modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + modifier = Modifier.padding(padding) ) { - SpaceViewContent(state) + SpaceViewContent( + state = state, + onRoomClick = onRoomClick + ) } }, ) } - @Composable private fun SpaceViewContent( state: SpaceState, + onRoomClick: (roomId: RoomId) -> Unit, modifier: Modifier = Modifier, -){ - LazyColumn(modifier) { - val parentSpace = state.parentSpace - if (parentSpace != null) { +) { + LazyColumn(modifier.fillMaxSize()) { + val currentSpace = state.currentSpace + if (currentSpace != null) { item { SpaceHeaderView( - avatarData = parentSpace.getAvatarData(AvatarSize.SpaceHeader), - name = parentSpace.name, - topic = parentSpace.topic, - joinRule = parentSpace.joinRule, - heroes = parentSpace.heroes.toImmutableList(), - numberOfMembers = parentSpace.numJoinedMembers, - numberOfRooms = parentSpace.childrenCount, + avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), + name = currentSpace.name, + topic = currentSpace.topic, + joinRule = currentSpace.joinRule, + heroes = currentSpace.heroes.toImmutableList(), + numberOfMembers = currentSpace.numJoinedMembers, + numberOfRooms = currentSpace.childrenCount, ) } } - state.children.forEach { - item(it.roomId) { - val isInvitation = it.state == CurrentUserMembership.INVITED + state.children.forEach { spaceRoom -> + item { + val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED SpaceRoomItemView( - spaceRoom = it, - showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, + spaceRoom = spaceRoom, + showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { - + onRoomClick(spaceRoom.roomId) }, onLongClick = { @@ -106,9 +110,33 @@ private fun SpaceViewContent( ) } } + if (state.hasMoreToLoad) { + item { + LoadingMoreIndicator(eventSink = state.eventSink) + } + } } } +@Composable +private fun LoadingMoreIndicator( + eventSink: (SpaceEvents) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(Unit) { + latestEventSink(SpaceEvents.LoadMore) + } + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -171,6 +199,7 @@ internal fun SpaceViewPreview( ) = ElementPreview { SpaceView( state = state, + onRoomClick = {}, onBackClick = {}, ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt index a1f79cdb0b..0b4bb3eae8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -16,6 +16,8 @@ interface SpaceRoomList { data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus } + fun currentSpaceFlow(): Flow + val spaceRoomsFlow: Flow> val paginationStatusFlow: StateFlow suspend fun paginate(): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt index 9691f63c12..29f226a730 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -8,10 +8,12 @@ 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 @@ -21,16 +23,27 @@ 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() + + override fun currentSpaceFlow(): Flow { + return spaceRoomCache.getSpaceRoomFlow(roomId) + } + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = Int.MAX_VALUE) override val paginationStatusFlow: MutableStateFlow = MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) - private val spaceListUpdateProcessor = SpaceListUpdateProcessor(spaceRoomsFlow, spaceRoomMapper) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache + ) init { sessionCoroutineScope.launch { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 3e8fd77843..58bacdd403 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -39,8 +39,13 @@ class RustSpaceService( private val sessionDispatcher: CoroutineDispatcher, ) : SpaceService { private val spaceRoomMapper = SpaceRoomMapper() + private val spaceRoomCache = SpaceRoomCache() override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) - private val spaceListUpdateProcessor = SpaceListUpdateProcessor(spaceRoomsFlow, spaceRoomMapper) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache + ) override suspend fun joinedSpaces(): Result> = withContext(sessionDispatcher) { runCatchingExceptions { @@ -53,9 +58,11 @@ class RustSpaceService( override fun spaceRoomList(id: RoomId): SpaceRoomList { return RustSpaceRoomList( + roomId = id, innerProvider = { innerSpaceService.spaceRoomList(id.value) }, sessionCoroutineScope = sessionCoroutineScope, - spaceRoomMapper = spaceRoomMapper + spaceRoomMapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt index a1f8584299..c968cc3edb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt @@ -18,6 +18,7 @@ import timber.log.Timber internal class SpaceListUpdateProcessor( private val spaceRoomsFlow: MutableSharedFlow>, private val mapper: SpaceRoomMapper, + private val spaceRoomCache: SpaceRoomCache, ) { private val mutex = Mutex() @@ -36,7 +37,8 @@ internal class SpaceListUpdateProcessor( mutableListOf() } block(spaceRooms) - spaceRoomsFlow.tryEmit(spaceRooms) + spaceRoomCache.update(spaceRooms) + spaceRoomsFlow.emit(spaceRooms) } private fun MutableList.applyUpdate(update: SpaceListUpdate) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt new file mode 100644 index 0000000000..79868336de --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt @@ -0,0 +1,44 @@ +/* + * 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 dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.SessionScope +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.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * 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>(LinkedHashMap()) + private val mutex = Mutex() + + fun getSpaceRoomFlow(roomId: RoomId): Flow { + return inMemoryCache.map { it[roomId] } + } + + suspend fun update(spaceRooms: List) = mutex.withLock { + inMemoryCache.update { cache -> + spaceRooms + .filter { it.isSpace } + .forEach { spaceRoom -> + cache.put(spaceRoom.roomId, spaceRoom) + } + cache + } + } +} diff --git a/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt new file mode 100644 index 0000000000..3acea6255b --- /dev/null +++ b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt @@ -0,0 +1,46 @@ +/* + * 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.previewutils.room + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.user.MatrixUser + +fun aSpaceRoom( + name: String? = "Space name", + avatarUrl: String? = null, + canonicalAlias: RoomAlias? = null, + childrenCount: Int = 0, + guestCanJoin: Boolean = false, + heroes: List = emptyList(), + joinRule: JoinRule? = null, + numJoinedMembers: Int = 0, + roomId: RoomId = RoomId("!roomId:example.com"), + roomType: RoomType = RoomType.Space, + state: CurrentUserMembership? = null, + topic: String? = null, + worldReadable: Boolean = false, +) = SpaceRoom( + name = name, + avatarUrl = avatarUrl, + canonicalAlias = canonicalAlias, + childrenCount = childrenCount, + guestCanJoin = guestCanJoin, + heroes = heroes, + joinRule = joinRule, + numJoinedMembers = numJoinedMembers, + roomId = roomId, + roomType = roomType, + state = state, + topic = topic, + worldReadable = worldReadable +) From f5882fef1198b974b3b892970948dc2d2dd4bfbb Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 10 Sep 2025 12:04:09 +0200 Subject: [PATCH 06/17] feature (space) : display top bar title --- .../element/android/features/space/impl/SpaceView.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt index b6077f4007..0213f94c5b 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -58,7 +58,7 @@ fun SpaceView( Scaffold( modifier = modifier, topBar = { - SpaceViewTopBar(spaceRoom = null, onBackClick = onBackClick) + SpaceViewTopBar(currentSpace = state.currentSpace, onBackClick = onBackClick) }, content = { padding -> Box( @@ -141,7 +141,7 @@ private fun LoadingMoreIndicator( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceViewTopBar( - spaceRoom: SpaceRoom?, + currentSpace: SpaceRoom?, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -151,10 +151,10 @@ private fun SpaceViewTopBar( BackButton(onClick = onBackClick) }, title = { - if (spaceRoom != null) { + if (currentSpace != null) { SpaceAvatarAndNameRow( - name = spaceRoom.name, - avatarData = spaceRoom.getAvatarData(AvatarSize.TimelineRoom), + name = currentSpace.name, + avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom), ) } }, From bb39a9f3995c22f7f1986aa59a941f2f3bb8ad7b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 10 Sep 2025 14:08:10 +0200 Subject: [PATCH 07/17] Code cleanup, remove dead code and fix compilation issue --- .../android/features/home/impl/spaces/HomeSpacesState.kt | 1 - .../android/features/home/impl/spaces/HomeSpacesView.kt | 2 +- .../android/features/invite/api/SeenInvitesStore.kt | 3 --- features/space/api/build.gradle.kts | 2 +- .../element/android/features/space/api/SpaceEntryPoint.kt | 2 +- features/space/impl/build.gradle.kts | 7 +++---- .../android/features/space/impl/DefaultSpaceEntryPoint.kt | 2 +- .../io/element/android/features/space/impl/SpaceNode.kt | 5 ++--- .../element/android/features/space/impl/SpacePresenter.kt | 3 --- .../io/element/android/features/space/impl/SpaceView.kt | 2 +- .../io/element/android/libraries/matrix/api/core/RoomId.kt | 2 -- .../android/libraries/matrix/api/spaces/SpaceRoomList.kt | 3 +-- .../libraries/matrix/impl/spaces/RustSpaceRoomList.kt | 1 - .../libraries/matrix/impl/spaces/RustSpaceService.kt | 1 - .../matrix/impl/spaces/SpaceListUpdateProcessor.kt | 4 +--- .../android/libraries/matrix/impl/spaces/SpaceRoomCache.kt | 5 +---- .../libraries/matrix/impl/spaces/SpaceRoomMapper.kt | 1 - 17 files changed, 13 insertions(+), 33 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt index 39871fb905..96733991f9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt @@ -8,7 +8,6 @@ package io.element.android.features.home.impl.spaces import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import kotlinx.collections.immutable.ImmutableSet diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index 51dac2e0a3..8b07b9f526 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -63,7 +63,7 @@ fun HomeSpacesView( onSpaceClick(spaceRoom.roomId) }, onLongClick = { - + // TODO } ) } diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt index 283deb78fb..682970ffe7 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt @@ -8,10 +8,7 @@ package io.element.android.features.invite.api import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SpaceId -import io.element.android.libraries.matrix.api.core.toSpaceId import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map interface SeenInvitesStore { /** diff --git a/features/space/api/build.gradle.kts b/features/space/api/build.gradle.kts index b9a7f3226e..dd19efefec 100644 --- a/features/space/api/build.gradle.kts +++ b/features/space/api/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2023, 2024 New Vector Ltd. + * 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. diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt index cc298de601..88cbbad5dd 100644 --- a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023, 2024 New Vector Ltd. + * 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. diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts index 5731b4a819..b41f8ebc93 100644 --- a/features/space/impl/build.gradle.kts +++ b/features/space/impl/build.gradle.kts @@ -1,8 +1,7 @@ -import extension.ComponentMergingStrategy -import extension.setupAnvil +import extension.setupDependencyInjection /* - * Copyright 2022-2024 New Vector Ltd. + * 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. @@ -23,7 +22,7 @@ android { } } -setupAnvil(componentMergingStrategy = ComponentMergingStrategy.KSP) +setupDependencyInjection() dependencies { implementation(projects.libraries.core) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt index 22988a6964..a20e82f9a2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -10,7 +10,7 @@ package io.element.android.features.space.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin -import com.squareup.anvil.annotations.ContributesBinding +import dev.zacsweers.metro.ContributesBinding import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt index c89e7d0aad..f826f9c499 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -24,9 +24,8 @@ class SpaceNode @AssistedInject constructor( @Assisted plugins: List, presenterFactory: SpacePresenter.Factory, ) : Node(buildContext, plugins = plugins) { - - val inputs = plugins.filterIsInstance().single() - val callback = plugins.filterIsInstance().single() + private val inputs = plugins.filterIsInstance().single() + private val callback = plugins.filterIsInstance().single() private val presenter = presenterFactory.create(inputs) @Composable diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt index 5efc207430..d06f1e8eb2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -35,7 +35,6 @@ class SpacePresenter( private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, ) : Presenter { - @AssistedFactory interface Factory { fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter @@ -45,11 +44,9 @@ class SpacePresenter( @Composable override fun present(): SpaceState { - LaunchedEffect(Unit) { paginate() } - val hideInvitesAvatar by remember { client .mediaPreviewService() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt index 0213f94c5b..f3bdf7379b 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -105,7 +105,7 @@ private fun SpaceViewContent( onRoomClick(spaceRoom.roomId) }, onLongClick = { - + // TODO } ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt index 1fab64020d..8d339ae704 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -20,5 +20,3 @@ value class RoomId(val value: String) : Serializable { override fun toString(): String = value } - -fun RoomId.toSpaceId(): SpaceId = SpaceId(this.value) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt index 0b4bb3eae8..a591fc2537 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023, 2024 New Vector Ltd. + * 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. @@ -22,4 +22,3 @@ interface SpaceRoomList { val paginationStatusFlow: StateFlow suspend fun paginate(): Result } - diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt index 29f226a730..e3f2132a2f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -29,7 +29,6 @@ class RustSpaceRoomList( spaceRoomMapper: SpaceRoomMapper, private val spaceRoomCache: SpaceRoomCache, ) : SpaceRoomList { - private val inner = CompletableDeferred() override fun currentSpaceFlow(): Flow { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 58bacdd403..2a63367577 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -9,7 +9,6 @@ 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.core.SpaceId 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 diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt index c968cc3edb..f1193661ea 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt @@ -44,9 +44,7 @@ internal class SpaceListUpdateProcessor( private fun MutableList.applyUpdate(update: SpaceListUpdate) { when (update) { is SpaceListUpdate.Append -> { - val newSpaces = update.values.map { it -> - it.let(mapper::map) - } + val newSpaces = update.values.map(mapper::map) addAll(newSpaces) } SpaceListUpdate.Clear -> clear() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt index 79868336de..63fe020ecd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt @@ -7,9 +7,6 @@ package io.element.android.libraries.matrix.impl.spaces -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import kotlinx.coroutines.flow.Flow @@ -23,7 +20,7 @@ import kotlinx.coroutines.sync.withLock * An in memory cache of space rooms. * Only caches Rooms with roomType [io.element.android.libraries.matrix.api.room.RoomType.Space]. */ -class SpaceRoomCache() { +class SpaceRoomCache { private val inMemoryCache = MutableStateFlow>(LinkedHashMap()) private val mutex = Mutex() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt index ade5063515..c217f941c7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt @@ -10,7 +10,6 @@ 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.RoomId -import io.element.android.libraries.matrix.api.core.SpaceId 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 From e2278853457f0a3be57ddd5503b2965cce47e4e0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 10 Sep 2025 14:58:18 +0200 Subject: [PATCH 08/17] More compilation fixes. --- .../features/space/impl/DefaultSpaceEntryPoint.kt | 5 +++-- .../io/element/android/features/space/impl/SpaceNode.kt | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt index a20e82f9a2..1ef8275b27 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -11,13 +11,14 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope -import javax.inject.Inject @ContributesBinding(SessionScope::class) -class DefaultSpaceEntryPoint @Inject constructor() : SpaceEntryPoint { +@Inject +class DefaultSpaceEntryPoint : SpaceEntryPoint { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder { val plugins = mutableSetOf() return object : SpaceEntryPoint.NodeBuilder { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt index f826f9c499..d37be4c76c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -12,14 +12,15 @@ import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.anvilannotations.ContributesNode +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.Inject +import io.element.android.annotations.ContributesNode import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) -class SpaceNode @AssistedInject constructor( +@Inject +class SpaceNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: SpacePresenter.Factory, From 115e0e64f67c93e66fc22ef40bfbc0cd4bcad7c5 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 10 Sep 2025 13:17:52 +0000 Subject: [PATCH 09/17] Update screenshots --- .../features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png | 3 --- .../features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png | 3 --- .../features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png | 3 --- .../features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png | 3 --- .../features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png | 3 --- ...features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png | 3 --- ...features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png | 3 --- ...features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png | 3 --- ...features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png | 3 --- ...features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png | 3 --- .../features.home.impl.spaces_HomeSpacesView_Day_0_en.png | 4 ++-- .../features.home.impl.spaces_HomeSpacesView_Night_0_en.png | 4 ++-- .../images/features.space.impl_SpaceView_Day_0_en.png | 3 +++ .../images/features.space.impl_SpaceView_Day_1_en.png | 3 +++ .../images/features.space.impl_SpaceView_Day_2_en.png | 3 +++ .../images/features.space.impl_SpaceView_Day_3_en.png | 3 +++ .../images/features.space.impl_SpaceView_Night_0_en.png | 3 +++ .../images/features.space.impl_SpaceView_Night_1_en.png | 3 +++ .../images/features.space.impl_SpaceView_Night_2_en.png | 3 +++ .../images/features.space.impl_SpaceView_Night_3_en.png | 3 +++ 20 files changed, 28 insertions(+), 34 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png deleted file mode 100644 index 4dc4712e67..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a241e70cffe61158784ac75425e13cd8d2695406f6e52a93ca7dddfdc9f99caa -size 14164 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png deleted file mode 100644 index dc4f56f39b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:864ab3319f7074643b58d2dd6da22a4c5493bae25502c89bf2c3503bc57cfe91 -size 11628 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png deleted file mode 100644 index 403a96cbad..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ba93e44b8732d7bf78b25200a5db6e8a329f970aa1fb6a002033c9ebfd09b805 -size 11723 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png deleted file mode 100644 index f12b559ded..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:59b6f98dc65ae4be92b7cdfde9778b390b6474205f75d48061d6345eece53675 -size 19034 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png deleted file mode 100644 index 833415fba7..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b4a8cef8dc61dd71458d7518548e83f5e4484fd9dc740fefc690769ffbf6bd80 -size 18949 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png deleted file mode 100644 index 175a0f0151..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7ce65c4dd01458194792d0a14eafc66e6e6f0a0e0f59259e4661322d0cf9a46d -size 13819 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png deleted file mode 100644 index 443230a159..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3e3765e543cce879b97a35b8c3e1cdef4b6c9ce4b1739b9d9a544d6a56118399 -size 11414 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png deleted file mode 100644 index 9ad249a00f..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:51b283fb8e976c8717abeef61dcf294f3efd52b7291fdf023c09759e6df6513d -size 11337 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png deleted file mode 100644 index ceafbe6d6a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c440b9628b8105b73f5f576c1b99269a74a89ab07f56080ad53cc888aca24da3 -size 18111 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png deleted file mode 100644 index 3cf9c17cda..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9ddecc15d26dbdd648ec7abbff51718da03326ebf32a2d675563110c096a198b -size 18141 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png index bd804824f9..f36c5aa998 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dff393d500d855842d84d311b3bb5cb60bab6fc2e8e9efe3b593d675218d5024 -size 123012 +oid sha256:4053802814238aa74a5c606a4b37e0afcb923282c3e7cece737b0ec4f6828351 +size 106332 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png index 6159775dd0..b77735afed 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9993c1937c780b4b7e3a47bfb53fa5735ac7bc20f29559281384ca32eb7c13ae -size 120701 +oid sha256:d02259f3163c49b5c39d38d7a84af50583f6a0ce2a66e8ad013ac18278de65c0 +size 103621 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png new file mode 100644 index 0000000000..59cd1f1de1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e69f830b7f15de531fc46efc02964fb641a4e3754b5108fb8020e97bbfb4b56 +size 15698 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png new file mode 100644 index 0000000000..393434d50b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5fba00f44ab63c07978ec0143f2f1fefc8fd1d78aed22a367e831c105b806c7 +size 17306 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png new file mode 100644 index 0000000000..2fa59b0ada --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c7b387db1b0637745dfc93559b9afa3c637e5a492bf19f6f1d0dbe802ed73bb +size 47156 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png new file mode 100644 index 0000000000..8cb2f6776c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9892ec0c06bfa6e0b3a95fd35efa97e44f2e1f6264a324d6fdd994847f1830d1 +size 45897 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png new file mode 100644 index 0000000000..a2c2d2043d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91ad7e392e21afe93606956e8e7eab7f0b075574e782ff15e873a71fc3ab16ab +size 15637 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png new file mode 100644 index 0000000000..67cdaf4a39 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d50be9f20ed0e75e337d790edb329996abf9e626591882088817301338fadb6 +size 17213 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png new file mode 100644 index 0000000000..6e55a5816e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d76f7b10d8c88f39ab36581be35dd2a5ca5e7c3a92c69b96d4f3ec87f2e93468 +size 46564 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png new file mode 100644 index 0000000000..298a35ce39 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:057ceb522ff354d0d7e8b73d59eb53c559f9c22a2727d24744dc6a34b9daaa55 +size 45034 From c9bc7bf28ad3099351b2295df833a9406fcfd64f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 10 Sep 2025 15:12:25 +0200 Subject: [PATCH 10/17] Fix test compilation issues. --- .../matrix/test/spaces/FakeSpaceService.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt index 54005b122b..43cc8ae8d2 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -7,7 +7,9 @@ package io.element.android.libraries.matrix.test.spaces +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.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -16,17 +18,22 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow class FakeSpaceService( - private val joinedSpacesResult: () -> Result> = { lambdaError() } + private val joinedSpacesResult: () -> Result> = { lambdaError() }, + private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, ) : SpaceService { - private val _spaceRooms = MutableSharedFlow>() - override val spaceRooms: SharedFlow> - get() = _spaceRooms.asSharedFlow() + private val _spaceRoomsFlow = MutableSharedFlow>() + override val spaceRoomsFlow: SharedFlow> + get() = _spaceRoomsFlow.asSharedFlow() suspend fun emitSpaceRoomList(value: List) { - _spaceRooms.emit(value) + _spaceRoomsFlow.emit(value) } override suspend fun joinedSpaces(): Result> = simulateLongTask { return joinedSpacesResult() } + + override fun spaceRoomList(id: RoomId): SpaceRoomList { + return spaceRoomListResult(id) + } } From 2128f3c0596eb1b2619ebd93aeea9352b8e95ae9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 10 Sep 2025 15:33:59 +0200 Subject: [PATCH 11/17] Introduce MatrixClient.rememberHideInvitesAvatar() extension to reduce code duplication. --- .../home/impl/roomlist/RoomListPresenter.kt | 9 ++----- .../home/impl/spaces/HomeSpacesPresenter.kt | 9 ++----- .../joinroom/impl/JoinRoomPresenter.kt | 9 ++----- .../features/space/impl/SpacePresenter.kt | 8 ++----- .../libraries/matrix/ui/safety/Avatars.kt | 24 +++++++++++++++++++ 5 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 9c77ab816a..b8e299c5e9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -36,7 +36,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -44,6 +43,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.push.api.battery.BatteryOptimizationState @@ -101,12 +101,7 @@ class RoomListPresenter( var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } // Avatar indicator - val hideInvitesAvatar by remember { - client - .mediaPreviewService() - .mediaPreviewConfigFlow - .mapState { config -> config.hideInviteAvatar } - }.collectAsState() + val hideInvitesAvatar by client.rememberHideInvitesAvatar() val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt index b29da29a94..dea6defc0a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt @@ -14,8 +14,8 @@ import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.map @@ -27,12 +27,7 @@ class HomeSpacesPresenter( ) : Presenter { @Composable override fun present(): HomeSpacesState { - val hideInvitesAvatar by remember { - client - .mediaPreviewService() - .mediaPreviewConfigFlow - .mapState { config -> config.hideInviteAvatar } - }.collectAsState() + val hideInvitesAvatar by client.rememberHideInvitesAvatar() val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList()) val seenSpaceInvites by remember { seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index 04e5b904e6..5fe12a42d9 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -34,7 +34,6 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState -import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -50,6 +49,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Optional @@ -93,12 +93,7 @@ class JoinRoomPresenter( val forgetRoomAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } var knockMessage by rememberSaveable { mutableStateOf("") } var isDismissingContent by remember { mutableStateOf(false) } - val hideInviteAvatars by remember { - matrixClient - .mediaPreviewService() - .mediaPreviewConfigFlow - .mapState { config -> config.hideInviteAvatar } - }.collectAsState() + val hideInviteAvatars by matrixClient.rememberHideInvitesAvatar() val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() } val contentState by produceState( diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt index d06f1e8eb2..95597371a6 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet @@ -47,12 +48,7 @@ class SpacePresenter( LaunchedEffect(Unit) { paginate() } - val hideInvitesAvatar by remember { - client - .mediaPreviewService() - .mediaPreviewConfigFlow - .mapState { config -> config.hideInviteAvatar } - }.collectAsState() + val hideInvitesAvatar by client.rememberHideInvitesAvatar() val seenSpaceInvites by remember { seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } }.collectAsState(persistentSetOf()) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt new file mode 100644 index 0000000000..0eb2b37ea4 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt @@ -0,0 +1,24 @@ +/* + * 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.ui.safety + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient + +@Composable +fun MatrixClient.rememberHideInvitesAvatar(): State { + return remember { + mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() +} From c982279d5c2cefd9f2a668185ba29cc66323b090 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 10 Sep 2025 16:12:52 +0200 Subject: [PATCH 12/17] Add test on SpacePresenter --- .../features/space/impl/SpacePresenterTest.kt | 167 ++++++++++++++++++ .../matrix/test/spaces/FakeSpaceRoomList.kt | 49 +++++ 2 files changed, 216 insertions(+) create mode 100644 features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt new file mode 100644 index 0000000000..0bcd1303ae --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt @@ -0,0 +1,167 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.space.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpacePresenterTest { + @Test + fun `present - initial state`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + paginateResult = paginateResult, + ) + }, + ), + ), + ) + presenter.test { + val state = awaitItem() + assertThat(state.currentSpace).isNull() + assertThat(state.children).isEmpty() + assertThat(state.seenSpaceInvites).isEmpty() + assertThat(state.hideInvitesAvatar).isFalse() + assertThat(state.hasMoreToLoad).isTrue() + advanceUntilIdle() + paginateResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - load more`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + paginateResult = paginateResult, + ) + }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + paginateResult.assertions().isCalledOnce() + state.eventSink(SpaceEvents.LoadMore) + advanceUntilIdle() + paginateResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - has more to load value`() = runTest { + val fakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { fakeSpaceRoomList }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.hasMoreToLoad).isTrue() + fakeSpaceRoomList.emitPaginationStatus( + SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false) + ) + assertThat(awaitItem().hasMoreToLoad).isFalse() + fakeSpaceRoomList.emitPaginationStatus( + SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true) + ) + assertThat(awaitItem().hasMoreToLoad).isTrue() + } + } + + @Test + fun `present - current space value`() = runTest { + val fakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { fakeSpaceRoomList }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.currentSpace).isNull() + val aSpace = aSpaceRoom() + fakeSpaceRoomList.emitCurrentSpace(aSpace) + assertThat(awaitItem().currentSpace).isEqualTo(aSpace) + } + } + + @Test + fun `present - children value`() = runTest { + val fakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { fakeSpaceRoomList }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.children).isEmpty() + val aSpace = aSpaceRoom() + fakeSpaceRoomList.emitSpaceRooms(listOf(aSpace)) + assertThat(awaitItem().children).containsExactly(aSpace) + } + } + + private fun createSpacePresenter( + inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID), + client: MatrixClient = FakeMatrixClient(), + seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + ): SpacePresenter { + return SpacePresenter( + inputs = inputs, + client = client, + seenInvitesStore = seenInvitesStore, + ) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt new file mode 100644 index 0000000000..eb71f33fa2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt @@ -0,0 +1,49 @@ +/* + * 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.test.spaces + +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeSpaceRoomList( + initialSpaceFlowValue: SpaceRoom? = null, + initialSpaceRoomsValue: List = emptyList(), + initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, + private val paginateResult: () -> Result = { lambdaError() }, +) : SpaceRoomList { + private val _currentSpaceFlow: MutableStateFlow = MutableStateFlow(initialSpaceFlowValue) + override fun currentSpaceFlow(): Flow = _currentSpaceFlow.asStateFlow() + + fun emitCurrentSpace(value: SpaceRoom?) { + _currentSpaceFlow.value = value + } + + private val _spaceRoomsFlow: MutableStateFlow> = MutableStateFlow(initialSpaceRoomsValue) + override val spaceRoomsFlow: Flow> = _spaceRoomsFlow.asStateFlow() + + fun emitSpaceRooms(value: List) { + _spaceRoomsFlow.value = value + } + + private val _paginationStatusFlow: MutableStateFlow = MutableStateFlow(initialSpaceRoomList) + override val paginationStatusFlow: StateFlow = _paginationStatusFlow.asStateFlow() + + fun emitPaginationStatus(value: SpaceRoomList.PaginationStatus) { + _paginationStatusFlow.value = value + } + + override suspend fun paginate(): Result = simulateLongTask { + paginateResult() + } +} From 959cb4f0b3ec66181c136f103ca3678d3a9486c8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 10 Sep 2025 18:18:16 +0200 Subject: [PATCH 13/17] Add test on SpaceRoomCache and fix implementation --- libraries/matrix/impl/build.gradle.kts | 1 + .../matrix/impl/spaces/SpaceRoomCache.kt | 25 +++++----- .../matrix/impl/spaces/SpaceRoomCacheTest.kt | 46 +++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index a0fa346be2..757d7aa1fc 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.previewutils) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt index 63fe020ecd..ce039b9471 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt @@ -11,31 +11,32 @@ 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.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap /** * 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>(LinkedHashMap()) + private val inMemoryCache = ConcurrentHashMap>() private val mutex = Mutex() fun getSpaceRoomFlow(roomId: RoomId): Flow { - return inMemoryCache.map { it[roomId] } + return getMutableFlow(roomId).asStateFlow() } suspend fun update(spaceRooms: List) = mutex.withLock { - inMemoryCache.update { cache -> - spaceRooms - .filter { it.isSpace } - .forEach { spaceRoom -> - cache.put(spaceRoom.roomId, spaceRoom) - } - cache - } + spaceRooms + .filter { it.isSpace } + .forEach { spaceRoom -> + getMutableFlow(spaceRoom.roomId).value = spaceRoom + } + } + + private fun getMutableFlow(roomId: RoomId): MutableStateFlow { + return inMemoryCache.getOrPut(roomId, { MutableStateFlow(null) }) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt new file mode 100644 index 0000000000..0679ce1a33 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt @@ -0,0 +1,46 @@ +/* + * 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 app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpaceRoomCacheTest { + @Test + fun `getSpaceRoomFlow emits items`() = runTest { + val sut = SpaceRoomCache() + sut.getSpaceRoomFlow(A_ROOM_ID).test { + assertThat(awaitItem()).isNull() + val room = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + sut.update(listOf(room)) + // Not a space, should not be cached + expectNoEvents() + val space = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Space, + ) + sut.update(listOf(space)) + assertThat(awaitItem()).isEqualTo(space) + val spaceOther = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Space, + ) + sut.update(listOf(spaceOther)) + expectNoEvents() + } + } +} From 697652a5f4dc301621832c94ce1a2e23dec2b875 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 10 Sep 2025 21:42:25 +0200 Subject: [PATCH 14/17] Iterate on SpaceRoomCache thanks to SpaceRoomCacheTest --- .../matrix/impl/spaces/SpaceRoomCache.kt | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt index ce039b9471..8b7d1f5e1b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt @@ -7,36 +7,29 @@ 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.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.util.concurrent.ConcurrentHashMap +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 = ConcurrentHashMap>() - private val mutex = Mutex() - + private val inMemoryCache = MutableStateFlow>(emptyMap()) fun getSpaceRoomFlow(roomId: RoomId): Flow { - return getMutableFlow(roomId).asStateFlow() + return inMemoryCache.mapState { it[roomId] } } - suspend fun update(spaceRooms: List) = mutex.withLock { - spaceRooms - .filter { it.isSpace } - .forEach { spaceRoom -> - getMutableFlow(spaceRoom.roomId).value = spaceRoom - } - } - - private fun getMutableFlow(roomId: RoomId): MutableStateFlow { - return inMemoryCache.getOrPut(roomId, { MutableStateFlow(null) }) + fun update(spaceRooms: List) { + inMemoryCache.update { currentValues -> + val newValues = spaceRooms + .filter { it.isSpace } + .associateBy { it.roomId } + currentValues + newValues + } } } From 4869c0b5d799fcb611faf0ec7fbfe9ef2e19178d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 11 Sep 2025 09:36:56 +0200 Subject: [PATCH 15/17] Add UT on SpaceListUpdateProcessor --- .../impl/fixtures/factories/SpaceRoom.kt | 46 +++++ .../spaces/RoomSummaryListProcessorTest.kt | 190 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt new file mode 100644 index 0000000000..c699ef8097 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt @@ -0,0 +1,46 @@ +/* + * 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.fixtures.factories + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import org.matrix.rustcomponents.sdk.JoinRule +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomHero +import org.matrix.rustcomponents.sdk.RoomType +import org.matrix.rustcomponents.sdk.SpaceRoom + +fun aRustSpaceRoom( + roomId: RoomId = A_ROOM_ID, + canonicalAlias: String? = null, + name: String? = null, + topic: String? = null, + avatarUrl: String? = null, + roomType: RoomType = RoomType.Space, + numJoinedMembers: ULong = 0uL, + joinRule: JoinRule? = null, + worldReadable: Boolean? = null, + guestCanJoin: Boolean = false, + childrenCount: ULong = 0uL, + state: Membership? = null, + heroes: List = emptyList(), +) = SpaceRoom( + roomId = roomId.value, + canonicalAlias = canonicalAlias, + name = name, + topic = topic, + avatarUrl = avatarUrl, + roomType = roomType, + numJoinedMembers = numJoinedMembers, + joinRule = joinRule, + worldReadable = worldReadable, + guestCanJoin = guestCanJoin, + childrenCount = childrenCount, + state = state, + heroes = heroes, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt new file mode 100644 index 0000000000..47c3a19a6b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt @@ -0,0 +1,190 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.SpaceListUpdate + +class RoomSummaryListProcessorTest { + private val spaceRoomsFlow = MutableStateFlow>(emptyList()) + + @Test + fun `Append adds new entries at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + + val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID_2) + processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry, newEntry, newEntry)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(4) + assertThat(spaceRoomsFlow.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue() + } + + @Test + fun `PushBack adds a new entry at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + processor.postUpdates(listOf(SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value.last().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `PushFront inserts a new entry at the start of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + processor.postUpdates(listOf(SpaceListUpdate.PushFront(aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value.first().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Set replaces an entry at some index`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Set(index.toUInt(), aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Insert inserts a new entry at the provided index`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Insert(index.toUInt(), aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Remove removes an entry at some index`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Remove(index.toUInt()))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `PopBack removes an entry at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.PopBack)) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Test + fun `PopFront removes an entry at the start of the list`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.PopFront)) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Clear removes all the entries`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + + processor.postUpdates(listOf(SpaceListUpdate.Clear)) + + assertThat(spaceRoomsFlow.value).isEmpty() + } + + @Test + fun `Truncate removes all entries after the provided length`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Truncate(1u))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Test + fun `Reset removes all entries and add the provided ones`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Reset(listOf(aRustSpaceRoom(A_ROOM_ID_3))))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_3) + } + + @Test + fun `When there is no replay cache SpaceListUpdateProcessor starts with an empty list`() = runTest { + val spaceRoomsSharedFlow = MutableSharedFlow>(replay = 1) + val processor = createProcessor( + spaceRoomsFlow = spaceRoomsSharedFlow, + ) + assertThat(spaceRoomsSharedFlow.replayCache).isEmpty() + val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID) + processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry)))) + assertThat(spaceRoomsSharedFlow.replayCache).hasSize(1) + assertThat(spaceRoomsSharedFlow.replayCache.first()).hasSize(1) + } + + private fun createProcessor( + spaceRoomsFlow: MutableSharedFlow> = this.spaceRoomsFlow, + ) = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = SpaceRoomMapper(), + spaceRoomCache = SpaceRoomCache(), + ) +} From ba372c018b80ecb3369a1fbfd457485caa383508 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 11 Sep 2025 10:15:23 +0200 Subject: [PATCH 16/17] Fix quality issue. --- .../libraries/matrix/test/spaces/FakeSpaceRoomList.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt index eb71f33fa2..cd5bf7eb53 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt @@ -22,11 +22,11 @@ class FakeSpaceRoomList( initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, private val paginateResult: () -> Result = { lambdaError() }, ) : SpaceRoomList { - private val _currentSpaceFlow: MutableStateFlow = MutableStateFlow(initialSpaceFlowValue) - override fun currentSpaceFlow(): Flow = _currentSpaceFlow.asStateFlow() + private val currentSpaceMutableStateFlow: MutableStateFlow = MutableStateFlow(initialSpaceFlowValue) + override fun currentSpaceFlow(): Flow = currentSpaceMutableStateFlow.asStateFlow() fun emitCurrentSpace(value: SpaceRoom?) { - _currentSpaceFlow.value = value + currentSpaceMutableStateFlow.value = value } private val _spaceRoomsFlow: MutableStateFlow> = MutableStateFlow(initialSpaceRoomsValue) From 3b41d4a7e809bfbceefcbd416ae111266d49d308 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 11 Sep 2025 14:51:56 +0200 Subject: [PATCH 17/17] Add tests on RustSpaceRoomList --- .../fixtures/fakes/FakeFfiSpaceRoomList.kt | 58 +++++++++ .../impl/spaces/RustSpaceRoomListTest.kt | 118 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt new file mode 100644 index 0000000000..171afd49fc --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt @@ -0,0 +1,58 @@ +/* + * 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.fixtures.fakes + +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import org.matrix.rustcomponents.sdk.NoPointer +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import org.matrix.rustcomponents.sdk.SpaceRoom +import org.matrix.rustcomponents.sdk.SpaceRoomList +import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener +import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener +import org.matrix.rustcomponents.sdk.TaskHandle +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState + +class FakeFfiSpaceRoomList( + private val paginateResult: () -> Unit = { lambdaError() }, + private val paginationStateResult: () -> SpaceRoomListPaginationState = { lambdaError() }, + private val roomsResult: () -> List = { lambdaError() }, +) : SpaceRoomList(NoPointer) { + private var spaceRoomListPaginationStateListener: SpaceRoomListPaginationStateListener? = null + private var spaceRoomListEntriesListener: SpaceRoomListEntriesListener? = null + + override suspend fun paginate() = simulateLongTask { + paginateResult() + } + + override fun paginationState(): SpaceRoomListPaginationState { + return paginationStateResult() + } + + override fun rooms(): List { + return roomsResult() + } + + override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle { + spaceRoomListPaginationStateListener = listener + return FakeFfiTaskHandle() + } + + fun triggerPaginationStateUpdate(state: SpaceRoomListPaginationState) { + spaceRoomListPaginationStateListener?.onUpdate(state) + } + + override fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle { + spaceRoomListEntriesListener = listener + return FakeFfiTaskHandle() + } + + fun triggerRoomListUpdate(rooms: List) { + spaceRoomListEntriesListener?.onUpdate(rooms) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt new file mode 100644 index 0000000000..6ef3259657 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt @@ -0,0 +1,118 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.spaces + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.previewutils.room.aSpaceRoom +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.SpaceListUpdate +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList + +class RustSpaceRoomListTest { + @Test + fun `paginationStatusFlow emits values`() = runTest { + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginationStateResult = { SpaceRoomListPaginationState.Idle(false) } + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.paginationStatusFlow.test { + // First value is the initial one + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + // First value after the subscription occurs + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Loading) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Loading) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(true)) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(false)) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)) + } + } + + @Test + fun `spaceRoomsFlow emits values`() = runTest { + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginationStateResult = { SpaceRoomListPaginationState.Idle(false) } + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.spaceRoomsFlow.test { + // Give time for the subscription to be set + runCurrent() + innerSpaceRoomList.triggerRoomListUpdate( + listOf( + SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2)) + ) + ) + val rooms = awaitItem() + assertThat(rooms).hasSize(1) + assertThat(rooms[0].roomId).isEqualTo(A_ROOM_ID_2) + } + } + + @Test + fun `paginate invokes paginate on the inner class`() = runTest { + val paginateResult = lambdaRecorder { } + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginateResult = paginateResult, + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.paginate() + paginateResult.assertions().isCalledOnce() + } + + @Test + fun `currentSpaceFlow reads value from the SpaceRoomCache`() = runTest { + val spaceRoomCache = SpaceRoomCache() + val sut = createRustSpaceRoomList( + spaceRoomCache = spaceRoomCache, + ) + sut.currentSpaceFlow().test { + assertThat(awaitItem()).isNull() + val spaceRoom = aSpaceRoom(roomId = A_ROOM_ID) + spaceRoomCache.update(listOf(spaceRoom)) + assertThat(awaitItem()).isEqualTo(spaceRoom) + } + } + + private fun TestScope.createRustSpaceRoomList( + roomId: RoomId = A_ROOM_ID, + innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(), + innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList }, + spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(), + spaceRoomCache: SpaceRoomCache = SpaceRoomCache(), + ): RustSpaceRoomList { + return RustSpaceRoomList( + roomId = roomId, + innerProvider = innerProvider, + sessionCoroutineScope = backgroundScope, + spaceRoomMapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache, + ) + } +}