feature(spaces) : start introducing SpaceScreen

This commit is contained in:
ganfra
2025-09-08 15:41:27 +02:00
committed by Benoit Marty
parent d3a9c12ac6
commit 2fe56f834f
12 changed files with 486 additions and 4 deletions

View File

@@ -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)
}

View File

@@ -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<String>)
}
}

View File

@@ -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)
}

View File

@@ -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<Plugin>()
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<SpaceNode>(buildContext, plugins = plugins.toList())
}
}
}
}

View File

@@ -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 {
}

View File

@@ -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<Plugin>,
private val presenter: SpacePresenter,
) : Node(buildContext, plugins = plugins) {
val params = plugins.filterIsInstance<SpaceEntryPoint.Params>().single()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SpaceView(
state = state,
onBackClick = ::navigateUp,
modifier = modifier
)
}
}

View File

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

View File

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

View File

@@ -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<SpaceState> {
override val values: Sequence<SpaceState>
get() = sequenceOf(
aSpaceState(),
// Add other states here
)
}
fun aSpaceState() = SpaceState(
parentSpace = null,
children = persistentListOf(),
eventSink = {}
)

View File

@@ -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 = {},
)
}

View File

@@ -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<List<SpaceRoom>>
suspend fun joinedSpaces(): Result<List<SpaceRoom>>
suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList
suspend fun spaceRoomList(id: RoomId): SpaceRoomList
}

View File

@@ -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,