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 b45a4c3b2c
commit d4d2aa1707
20 changed files with 278 additions and 101 deletions

View file

@ -21,8 +21,8 @@ class DefaultSpaceEntryPoint @Inject constructor() : SpaceEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder {
val plugins = mutableSetOf<Plugin>()
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
}

View file

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

View file

@ -22,11 +22,12 @@ import io.element.android.libraries.di.SessionScope
class SpaceNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: SpacePresenter.Factory,
presenterFactory: SpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
val params = plugins.filterIsInstance<SpaceEntryPoint.Params>().single()
private val presenter = presenterFactory.create(params)
val inputs = plugins.filterIsInstance<SpaceEntryPoint.Inputs>().single()
val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().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
)
}

View file

@ -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<SpaceState> {
@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()
}
}

View file

@ -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<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean,
val eventSink: (SpaceEvents) -> Unit
)

View file

@ -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<SpaceState> {
override val values: Sequence<SpaceState>
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<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 = {}
)
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.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 = {},
)
}