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

This commit is contained in:
ganfra 2025-09-10 10:48:34 +02:00 committed by Benoit Marty
parent 6b9afd9e6f
commit 65961ad404
20 changed files with 278 additions and 101 deletions

View file

@ -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.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint 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.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
@ -334,7 +335,7 @@ class LoggedInFlowNode(
.build() .build()
} }
is NavTarget.Room -> { is NavTarget.Room -> {
val callback = object : JoinedRoomLoadedFlowNode.Callback { val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) { override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
} }
@ -373,6 +374,11 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) 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( val inputs = RoomFlowNode.Inputs(
roomIdOrAlias = navTarget.roomIdOrAlias, roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription), roomDescription = Optional.ofNullable(navTarget.roomDescription),
@ -380,7 +386,7 @@ class LoggedInFlowNode(
trigger = Optional.ofNullable(navTarget.trigger), trigger = Optional.ofNullable(navTarget.trigger),
initialElement = navTarget.initialElement initialElement = navTarget.initialElement
) )
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback))
} }
is NavTarget.UserProfile -> { is NavTarget.UserProfile -> {
val callback = object : UserProfileEntryPoint.Callback { val callback = object : UserProfileEntryPoint.Callback {

View file

@ -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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId 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.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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
@ -216,8 +215,10 @@ class RoomFlowNode(
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
is NavTarget.Space -> { is NavTarget.Space -> {
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
spaceEntryPoint.nodeBuilder(this, buildContext) spaceEntryPoint.nodeBuilder(this, buildContext)
.params(SpaceEntryPoint.Params.Id(navTarget.spaceId)) .inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId))
.callback(spaceCallback)
.build() .build()
} }
} }

View file

@ -55,6 +55,7 @@ dependencies {
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.changeroommemberroles.api) implementation(projects.features.changeroommemberroles.api)
implementation(projects.libraries.previewutils)
api(projects.features.home.api) api(projects.features.home.api)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)

View file

@ -10,6 +10,7 @@ package io.element.android.features.home.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId 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.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> { open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {

View file

@ -8,13 +8,10 @@
package io.element.android.features.home.impl.spaces package io.element.android.features.home.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership 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.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.previewutils.room.aSpaceRoom
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> { class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
override val values: Sequence<SpaceRoom> = sequenceOf( override val values: Sequence<SpaceRoom> = sequenceOf(
@ -52,33 +49,3 @@ class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
), ),
) )
} }
fun aSpaceRoom(
name: String? = "Space name",
avatarUrl: String? = null,
canonicalAlias: RoomAlias? = null,
childrenCount: Int = 0,
guestCanJoin: Boolean = false,
heroes: List<MatrixUser> = 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
)

View file

@ -12,8 +12,6 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId 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 { interface SpaceEntryPoint : FeatureEntryPoint {
fun nodeBuilder( fun nodeBuilder(
@ -22,24 +20,16 @@ interface SpaceEntryPoint : FeatureEntryPoint {
): NodeBuilder ): NodeBuilder
interface NodeBuilder { interface NodeBuilder {
fun params(params: Params): NodeBuilder fun inputs(inputs: Inputs): NodeBuilder
fun callback(callback: Callback): NodeBuilder fun callback(callback: Callback): NodeBuilder
fun build(): Node fun build(): Node
} }
sealed interface Params : Plugin { data class Inputs(
data class Id(val roomId: RoomId) : Params val roomId: RoomId
data class Full(val spaceRoom: SpaceRoom) : Params ) : Plugin
fun roomId(): RoomId {
return when (this) {
is Id -> roomId
is Full -> spaceRoom.roomId
}
}
}
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) fun onOpenRoom(roomId: RoomId)
} }
} }

View file

@ -38,6 +38,7 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.features.invite.api) implementation(projects.features.invite.api)
implementation(projects.libraries.previewutils)
api(projects.features.space.api) api(projects.features.space.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -21,8 +21,8 @@ class DefaultSpaceEntryPoint @Inject constructor() : SpaceEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder {
val plugins = mutableSetOf<Plugin>() val plugins = mutableSetOf<Plugin>()
return object : SpaceEntryPoint.NodeBuilder { return object : SpaceEntryPoint.NodeBuilder {
override fun params(params: SpaceEntryPoint.Params): SpaceEntryPoint.NodeBuilder { override fun inputs(inputs: SpaceEntryPoint.Inputs): SpaceEntryPoint.NodeBuilder {
plugins.add(params) plugins.add(inputs)
return this return this
} }

View file

@ -8,5 +8,5 @@
package io.element.android.features.space.impl package io.element.android.features.space.impl
sealed interface SpaceEvents { sealed interface SpaceEvents {
data object LoadMore : SpaceEvents
} }

View file

@ -22,11 +22,12 @@ import io.element.android.libraries.di.SessionScope
class SpaceNode @AssistedInject constructor( class SpaceNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenterFactory: SpacePresenter.Factory, presenterFactory: SpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
val params = plugins.filterIsInstance<SpaceEntryPoint.Params>().single() val inputs = plugins.filterIsInstance<SpaceEntryPoint.Inputs>().single()
private val presenter = presenterFactory.create(params) val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single()
private val presenter = presenterFactory.create(inputs)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -34,6 +35,9 @@ class SpaceNode @AssistedInject constructor(
SpaceView( SpaceView(
state = state, state = state,
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onRoomClick = { roomId ->
callback.onOpenRoom(roomId)
},
modifier = modifier modifier = modifier
) )
} }

View file

@ -8,9 +8,11 @@
package io.element.android.features.space.impl package io.element.android.features.space.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject 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.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.MatrixClient 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.persistentSetOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@Inject @Inject
class SpacePresenter( class SpacePresenter(
@Assisted private val params: SpaceEntryPoint.Params, @Assisted private val inputs: SpaceEntryPoint.Inputs,
private val client: MatrixClient, private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
) : Presenter<SpaceState> { ) : Presenter<SpaceState> {
@AssistedFactory @AssistedFactory
interface Factory { 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 @Composable
override fun present(): SpaceState { override fun present(): SpaceState {
LaunchedEffect(Unit) {
paginate()
}
val hideInvitesAvatar by remember { val hideInvitesAvatar by remember {
client client
.mediaPreviewService() .mediaPreviewService()
@ -50,18 +60,35 @@ class SpacePresenter(
seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } seenInvitesStore.seenRoomIds().map { it.toPersistentSet() }
}.collectAsState(persistentSetOf()) }.collectAsState(persistentSetOf())
val coroutineScope = rememberCoroutineScope()
val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList()) 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) { fun handleEvents(event: SpaceEvents) {
//when (event) { } when (event) {
SpaceEvents.LoadMore -> coroutineScope.paginate()
}
} }
return SpaceState( return SpaceState(
parentSpace = null, currentSpace = currentSpace,
children = children.toPersistentList(), children = children.toPersistentList(),
seenSpaceInvites = seenSpaceInvites, seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate()
}
} }

View file

@ -13,9 +13,10 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
data class SpaceState( data class SpaceState(
val parentSpace: SpaceRoom?, val currentSpace: SpaceRoom?,
val children: ImmutableList<SpaceRoom>, val children: ImmutableList<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<RoomId>, val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean, val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean,
val eventSink: (SpaceEvents) -> Unit val eventSink: (SpaceEvents) -> Unit
) )

View file

@ -8,18 +8,53 @@
package io.element.android.features.space.impl package io.element.android.features.space.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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<SpaceState> { open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
override val values: Sequence<SpaceState> override val values: Sequence<SpaceState>
get() = sequenceOf( get() = sequenceOf(
aSpaceState(), aSpaceState(),
aSpaceState(hasMoreToLoad = true),
aSpaceState(
hasMoreToLoad = true,
children = aListOfSpaceRooms(),
),
aSpaceState(
hasMoreToLoad = false,
children = aListOfSpaceRooms()
)
// Add other states here // Add other states here
) )
} }
fun aSpaceState() = SpaceState( fun aSpaceState(
parentSpace = null, parentSpace: SpaceRoom? = aSpaceRoom(
children = persistentListOf(), numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
roomId = RoomId("!spaceId0:example.com"),
),
children: List<SpaceRoom> = emptyList(),
seenSpaceInvites: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = false,
) = SpaceState(
currentSpace = parentSpace,
children = children.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
eventSink = {} eventSink = {}
) )
private fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf(
aSpaceRoom(roomId = RoomId("!spaceId0:example.com")),
aSpaceRoom(roomId = RoomId("!spaceId1:example.com")),
aSpaceRoom(roomId = RoomId("!spaceId2:example.com")),
)
}

View file

@ -9,13 +9,15 @@ package io.element.android.features.space.impl
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom 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.SpaceHeaderView
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -49,56 +52,57 @@ import kotlinx.collections.immutable.toImmutableList
fun SpaceView( fun SpaceView(
state: SpaceState, state: SpaceState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onRoomClick: (roomId: RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = { topBar = {
SpaceViewTopBar(spaceRoom = null, onBackClick = onBackClick) SpaceViewTopBar(spaceRoom = null, onBackClick = onBackClick)
}, },
content = { padding -> content = { padding ->
Box( Box(
modifier = Modifier modifier = Modifier.padding(padding)
.padding(padding)
.consumeWindowInsets(padding)
) { ) {
SpaceViewContent(state) SpaceViewContent(
state = state,
onRoomClick = onRoomClick
)
} }
}, },
) )
} }
@Composable @Composable
private fun SpaceViewContent( private fun SpaceViewContent(
state: SpaceState, state: SpaceState,
onRoomClick: (roomId: RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
){ ) {
LazyColumn(modifier) { LazyColumn(modifier.fillMaxSize()) {
val parentSpace = state.parentSpace val currentSpace = state.currentSpace
if (parentSpace != null) { if (currentSpace != null) {
item { item {
SpaceHeaderView( SpaceHeaderView(
avatarData = parentSpace.getAvatarData(AvatarSize.SpaceHeader), avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader),
name = parentSpace.name, name = currentSpace.name,
topic = parentSpace.topic, topic = currentSpace.topic,
joinRule = parentSpace.joinRule, joinRule = currentSpace.joinRule,
heroes = parentSpace.heroes.toImmutableList(), heroes = currentSpace.heroes.toImmutableList(),
numberOfMembers = parentSpace.numJoinedMembers, numberOfMembers = currentSpace.numJoinedMembers,
numberOfRooms = parentSpace.childrenCount, numberOfRooms = currentSpace.childrenCount,
) )
} }
} }
state.children.forEach { state.children.forEach { spaceRoom ->
item(it.roomId) { item {
val isInvitation = it.state == CurrentUserMembership.INVITED val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
SpaceRoomItemView( SpaceRoomItemView(
spaceRoom = it, spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
hideAvatars = isInvitation && state.hideInvitesAvatar, hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = { onClick = {
onRoomClick(spaceRoom.roomId)
}, },
onLongClick = { 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -171,6 +199,7 @@ internal fun SpaceViewPreview(
) = ElementPreview { ) = ElementPreview {
SpaceView( SpaceView(
state = state, state = state,
onRoomClick = {},
onBackClick = {}, onBackClick = {},
) )
} }

View file

@ -16,6 +16,8 @@ interface SpaceRoomList {
data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus
} }
fun currentSpaceFlow(): Flow<SpaceRoom?>
val spaceRoomsFlow: Flow<List<SpaceRoom>> val spaceRoomsFlow: Flow<List<SpaceRoom>>
val paginationStatusFlow: StateFlow<PaginationStatus> val paginationStatusFlow: StateFlow<PaginationStatus>
suspend fun paginate(): Result<Unit> suspend fun paginate(): Result<Unit>

View file

@ -8,10 +8,12 @@
package io.element.android.libraries.matrix.impl.spaces package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.core.extensions.runCatchingExceptions 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.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -21,16 +23,27 @@ import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomList( class RustSpaceRoomList(
private val roomId: RoomId,
private val innerProvider: suspend () -> InnerSpaceRoomList, private val innerProvider: suspend () -> InnerSpaceRoomList,
sessionCoroutineScope: CoroutineScope, sessionCoroutineScope: CoroutineScope,
spaceRoomMapper: SpaceRoomMapper, spaceRoomMapper: SpaceRoomMapper,
private val spaceRoomCache: SpaceRoomCache,
) : SpaceRoomList { ) : SpaceRoomList {
private val inner = CompletableDeferred<InnerSpaceRoomList>() private val inner = CompletableDeferred<InnerSpaceRoomList>()
override fun currentSpaceFlow(): Flow<SpaceRoom?> {
return spaceRoomCache.getSpaceRoomFlow(roomId)
}
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = Int.MAX_VALUE) override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
override val paginationStatusFlow: MutableStateFlow<SpaceRoomList.PaginationStatus> = override val paginationStatusFlow: MutableStateFlow<SpaceRoomList.PaginationStatus> =
MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false))
private val spaceListUpdateProcessor = SpaceListUpdateProcessor(spaceRoomsFlow, spaceRoomMapper) private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
spaceRoomsFlow = spaceRoomsFlow,
mapper = spaceRoomMapper,
spaceRoomCache = spaceRoomCache
)
init { init {
sessionCoroutineScope.launch { sessionCoroutineScope.launch {

View file

@ -39,8 +39,13 @@ class RustSpaceService(
private val sessionDispatcher: CoroutineDispatcher, private val sessionDispatcher: CoroutineDispatcher,
) : SpaceService { ) : SpaceService {
private val spaceRoomMapper = SpaceRoomMapper() private val spaceRoomMapper = SpaceRoomMapper()
private val spaceRoomCache = SpaceRoomCache()
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1) override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(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<List<SpaceRoom>> = withContext(sessionDispatcher) { override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions { runCatchingExceptions {
@ -53,9 +58,11 @@ class RustSpaceService(
override fun spaceRoomList(id: RoomId): SpaceRoomList { override fun spaceRoomList(id: RoomId): SpaceRoomList {
return RustSpaceRoomList( return RustSpaceRoomList(
roomId = id,
innerProvider = { innerSpaceService.spaceRoomList(id.value) }, innerProvider = { innerSpaceService.spaceRoomList(id.value) },
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
spaceRoomMapper = spaceRoomMapper spaceRoomMapper = spaceRoomMapper,
spaceRoomCache = spaceRoomCache,
) )
} }

View file

@ -18,6 +18,7 @@ import timber.log.Timber
internal class SpaceListUpdateProcessor( internal class SpaceListUpdateProcessor(
private val spaceRoomsFlow: MutableSharedFlow<List<SpaceRoom>>, private val spaceRoomsFlow: MutableSharedFlow<List<SpaceRoom>>,
private val mapper: SpaceRoomMapper, private val mapper: SpaceRoomMapper,
private val spaceRoomCache: SpaceRoomCache,
) { ) {
private val mutex = Mutex() private val mutex = Mutex()
@ -36,7 +37,8 @@ internal class SpaceListUpdateProcessor(
mutableListOf() mutableListOf()
} }
block(spaceRooms) block(spaceRooms)
spaceRoomsFlow.tryEmit(spaceRooms) spaceRoomCache.update(spaceRooms)
spaceRoomsFlow.emit(spaceRooms)
} }
private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) { private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {

View file

@ -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<MutableMap<RoomId, SpaceRoom>>(LinkedHashMap())
private val mutex = Mutex()
fun getSpaceRoomFlow(roomId: RoomId): Flow<SpaceRoom?> {
return inMemoryCache.map { it[roomId] }
}
suspend fun update(spaceRooms: List<SpaceRoom>) = mutex.withLock {
inMemoryCache.update { cache ->
spaceRooms
.filter { it.isSpace }
.forEach { spaceRoom ->
cache.put(spaceRoom.roomId, spaceRoom)
}
cache
}
}
}

View file

@ -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<MatrixUser> = 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
)