From 65961ad404f1a960c533f8afb1e3dbfd23ec9cd0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 10 Sep 2025 10:48:34 +0200 Subject: [PATCH] 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 +)