diff --git a/features/space/api/build.gradle.kts b/features/space/api/build.gradle.kts new file mode 100644 index 0000000000..b9a7f3226e --- /dev/null +++ b/features/space/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.space.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt new file mode 100644 index 0000000000..813450d7cb --- /dev/null +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.spaces.SpaceRoom + +interface SpaceEntryPoint : FeatureEntryPoint { + fun nodeBuilder( + parentNode: Node, + buildContext: BuildContext, + ): NodeBuilder + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + sealed interface Params : Plugin { + data class Id(val roomId: RoomId) : Params + data class Full(val spaceRoom: SpaceRoom) : Params + } + + interface Callback : Plugin { + fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List) + } +} diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts new file mode 100644 index 0000000000..5a397fa269 --- /dev/null +++ b/features/space/impl/build.gradle.kts @@ -0,0 +1,57 @@ +import extension.ComponentMergingStrategy +import extension.setupAnvil + +/* + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.space.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupAnvil(componentMergingStrategy = ComponentMergingStrategy.KSP) + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.deeplink.api) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(projects.libraries.featureflag.api) + implementation(projects.features.invite.api) + api(projects.features.space.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.features.invite.test) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt new file mode 100644 index 0000000000..9a0f10a15b --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultSpaceEntryPoint @Inject constructor() : SpaceEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder { + val plugins = mutableSetOf() + return object : SpaceEntryPoint.NodeBuilder { + override fun params(params: SpaceEntryPoint.Params): SpaceEntryPoint.NodeBuilder { + plugins.add(params) + return this + } + + override fun callback(callback: SpaceEntryPoint.Callback): SpaceEntryPoint.NodeBuilder { + plugins.add(callback) + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins = plugins.toList()) + } + } + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt new file mode 100644 index 0000000000..16a521f6dd --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +sealed interface SpaceEvents { + +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt new file mode 100644 index 0000000000..950cfe17d7 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class SpaceNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SpacePresenter, +) : Node(buildContext, plugins = plugins) { + + val params = plugins.filterIsInstance().single() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SpaceView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt new file mode 100644 index 0000000000..b4a764b409 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class SpacePresenter @Inject constructor( + private val client: MatrixClient, + private val seenInvitesStore: SeenInvitesStore, +) : Presenter { + + @Composable + override fun present(): SpaceState { + val hideInvitesAvatar by remember { + client + .mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() + val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList()) + val seenSpaceInvites by remember { + seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } + }.collectAsState(persistentSetOf()) + + fun handleEvents(event: SpaceEvents) { + //when (event) { } + } + + return SpaceState( + parentSpace = null, + children = spaceRooms.toPersistentList(), + seenSpaceInvites = seenSpaceInvites, + hideInvitesAvatar = hideInvitesAvatar, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt new file mode 100644 index 0000000000..ad3913af3d --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class SpaceState( + val parentSpace: SpaceRoom?, + val children: ImmutableList, + val seenSpaceInvites: ImmutableSet, + val hideInvitesAvatar: Boolean, + val eventSink: (SpaceEvents) -> Unit +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt new file mode 100644 index 0000000000..b5cb3e9546 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlinx.collections.immutable.persistentListOf + +open class SpaceStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceState(), + // Add other states here + ) +} + +fun aSpaceState() = SpaceState( + parentSpace = null, + children = persistentListOf(), + eventSink = {} +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt new file mode 100644 index 0000000000..d1774d98bf --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView +import io.element.android.libraries.matrix.ui.components.SpaceHeaderView +import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun SpaceView( + state: SpaceState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets.statusBars, + topBar = { + SpaceViewTopBar(spaceRoom = null, onBackClick = onBackClick) + }, + content = { padding -> + Box( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + SpaceViewContent(state) + } + }, + ) +} + + +@Composable +private fun SpaceViewContent( + state: SpaceState, + modifier: Modifier = Modifier, +){ + LazyColumn(modifier) { + val parentSpace = state.parentSpace + if (parentSpace != null) { + item { + SpaceHeaderView( + avatarData = parentSpace.getAvatarData(AvatarSize.SpaceHeader), + name = parentSpace.name, + topic = parentSpace.topic, + joinRule = parentSpace.joinRule, + heroes = parentSpace.heroes.toImmutableList(), + numberOfMembers = parentSpace.numJoinedMembers, + numberOfRooms = parentSpace.childrenCount, + ) + } + } + state.children.forEach { + item(it.roomId) { + val isInvitation = it.state == CurrentUserMembership.INVITED + SpaceRoomItemView( + spaceRoom = it, + showUnreadIndicator = isInvitation && it.roomId !in state.seenSpaceInvites, + hideAvatars = isInvitation && state.hideInvitesAvatar, + onClick = { + + }, + onLongClick = { + + } + ) + } + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpaceViewTopBar( + spaceRoom: SpaceRoom?, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + if (spaceRoom != null) { + SpaceAvatarAndNameRow( + name = spaceRoom.name, + avatarData = spaceRoom.getAvatarData(AvatarSize.TimelineRoom), + ) + } + }, + actions = { + }, + windowInsets = WindowInsets(0.dp) + ) +} + +@Composable +private fun SpaceAvatarAndNameRow( + name: String?, + avatarData: AvatarData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(), + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + text = name ?: stringResource(CommonStrings.common_no_room_name), + style = ElementTheme.typography.fontBodyLgMedium, + fontStyle = FontStyle.Italic.takeIf { name == null }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceViewPreview( + @PreviewParameter(SpaceStateProvider::class) state: SpaceState +) = ElementPreview { + SpaceView( + state = state, + onBackClick = {}, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 917369a6a7..22f78bf780 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -7,12 +7,12 @@ package io.element.android.libraries.matrix.api.spaces -import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.SharedFlow interface SpaceService { val spaceRoomsFlow: SharedFlow> suspend fun joinedSpaces(): Result> - suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList + suspend fun spaceRoomList(id: RoomId): SpaceRoomList } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 4d26040021..da4f62ad0c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.impl.spaces import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList @@ -50,8 +51,8 @@ class RustSpaceService( } } - override suspend fun spaceRoomList(spaceId: SpaceId): SpaceRoomList { - val innerSpaceRoomList = innerSpaceService.spaceRoomList(spaceId.value) + override suspend fun spaceRoomList(id: RoomId): SpaceRoomList { + val innerSpaceRoomList = innerSpaceService.spaceRoomList(id.value) return RustSpaceRoomList( inner = innerSpaceRoomList, sessionCoroutineScope = sessionCoroutineScope,