First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
*/
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,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.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.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
interface SpaceEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: Inputs,
callback: Callback
): Node
data class Inputs(
val roomId: RoomId
) : NodeInputs
interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, viaParameters: List<String>)
fun navigateToRoomMemberList()
}
}

View File

@@ -0,0 +1,50 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.space.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
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)
implementation(projects.libraries.previewutils)
api(projects.features.space.api)
testCommonDependencies(libs, true)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.invite.test)
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 dev.zacsweers.metro.ContributesBinding
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultSpaceEntryPoint : SpaceEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: SpaceEntryPoint.Inputs,
callback: SpaceEntryPoint.Callback,
): Node {
return parentNode.createNode<SpaceFlowNode>(
buildContext = buildContext,
plugins = listOf(inputs, callback),
)
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.space.impl
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.SpaceFlowGraph
import io.element.android.features.space.impl.leave.LeaveSpaceNode
import io.element.android.features.space.impl.root.SpaceNode
import io.element.android.features.space.impl.settings.SpaceSettingsNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@AssistedInject
class SpaceFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
room: JoinedRoom,
spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory,
) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DependencyInjectionGraphOwner {
private val callback: SpaceEntryPoint.Callback = callback()
private val spaceRoomList = spaceService.spaceRoomList(room.roomId)
override val graph = graphFactory.create(spaceRoomList)
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object Settings : NavTarget
@Parcelize
data object Leave : NavTarget
}
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
spaceRoomList.destroy()
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Leave -> {
val callback = object : LeaveSpaceNode.Callback {
override fun closeLeaveSpaceFlow() {
backstack.pop()
}
override fun navigateToRolesAndPermissions() {
// TODO
}
}
createNode<LeaveSpaceNode>(buildContext, listOf(callback))
}
NavTarget.Root -> {
val callback = object : SpaceNode.Callback {
override fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) {
callback.navigateToRoom(roomId, viaParameters)
}
override fun navigateToSpaceSettings() {
backstack.push(NavTarget.Settings)
}
override fun navigateToRoomMemberList() {
callback.navigateToRoomMemberList()
}
override fun startLeaveSpaceFlow() {
backstack.push(NavTarget.Leave)
}
}
createNode<SpaceNode>(buildContext, listOf(callback))
}
NavTarget.Settings -> {
val callback = object : SpaceSettingsNode.Callback {
override fun closeSettings() {
backstack.pop()
}
override fun navigateToSpaceInfo() {
// TODO
}
override fun navigateToSpaceMembers() {
callback.navigateToRoomMemberList()
}
override fun navigateToRolesAndPermissions() {
// TODO
}
override fun navigateToSecurityAndPrivacy() {
// TODO
}
override fun startLeaveSpaceFlow() {
backstack.push(NavTarget.Leave)
}
}
createNode<SpaceSettingsNode>(buildContext, listOf(callback))
}
}
}
@Composable
override fun View(modifier: Modifier) = BackstackView()
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides
import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
@GraphExtension(SpaceFlowScope::class)
interface SpaceFlowGraph : NodeFactoriesBindings {
@ContributesTo(RoomScope::class)
@GraphExtension.Factory
interface Factory {
fun create(@Provides spaceRoomList: SpaceRoomList): SpaceFlowGraph
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
abstract class SpaceFlowScope private constructor()

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveSpaceEvents {
data object Retry : LeaveSpaceEvents
data object SelectAllRooms : LeaveSpaceEvents
data object DeselectAllRooms : LeaveSpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents
data object LeaveSpace : LeaveSpaceEvents
data object CloseError : LeaveSpaceEvents
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesNode(SpaceFlowScope::class)
@AssistedInject
class LeaveSpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
matrixClient: MatrixClient,
room: JoinedRoom,
presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun closeLeaveSpaceFlow()
fun navigateToRolesAndPermissions()
}
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId)
private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle)
private val callback: Callback = callback()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
leaveSpaceHandle.close()
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LeaveSpaceView(
state = state,
onCancel = callback::closeLeaveSpaceFlow,
onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.map
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@AssistedInject
class LeaveSpacePresenter(
@Assisted private val leaveSpaceHandle: LeaveSpaceHandle,
) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter
}
data class LeaveSpaceRooms(
val current: LeaveSpaceRoom?,
val others: List<LeaveSpaceRoom>,
)
@Composable
override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
var selectedRoomIds by remember {
mutableStateOf<Collection<RoomId>>(setOf())
}
var leaveSpaceRooms by remember {
mutableStateOf<AsyncData<LeaveSpaceRooms>>(AsyncData.Loading())
}
LaunchedEffect(retryCount) {
val rooms = leaveSpaceHandle.rooms()
val (currentRoom, otherRooms) = rooms.getOrNull()
.orEmpty()
.partition { it.spaceRoom.roomId == leaveSpaceHandle.id }
// By default select all rooms that can be left
val otherRoomsExcludingDm = otherRooms.filter { it.spaceRoom.isDirect != true }
selectedRoomIds = otherRoomsExcludingDm
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
leaveSpaceRooms = rooms.fold(
onSuccess = {
AsyncData.Success(
LeaveSpaceRooms(
current = currentRoom.firstOrNull(),
others = otherRoomsExcludingDm.toImmutableList(),
)
)
},
onFailure = { AsyncData.Failure(it) }
)
}
var selectableSpaceRooms by remember {
mutableStateOf<AsyncData<ImmutableList<SelectableSpaceRoom>>>(AsyncData.Loading())
}
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
selectableSpaceRooms = leaveSpaceRooms.map {
it.others.map { room ->
SelectableSpaceRoom(
spaceRoom = room.spaceRoom,
isLastAdmin = room.isLastAdmin,
isSelected = selectedRoomIds.contains(room.spaceRoom.roomId),
)
}.toImmutableList()
}
}
fun handleEvent(event: LeaveSpaceEvents) {
when (event) {
LeaveSpaceEvents.Retry -> {
leaveSpaceRooms = AsyncData.Loading()
retryCount += 1
}
LeaveSpaceEvents.DeselectAllRooms -> {
selectedRoomIds = persistentSetOf()
}
LeaveSpaceEvents.SelectAllRooms -> {
selectedRoomIds = selectableSpaceRooms.dataOrNull()
.orEmpty()
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
}
is LeaveSpaceEvents.ToggleRoomSelection -> {
selectedRoomIds = if (selectedRoomIds.contains(event.roomId)) {
selectedRoomIds - event.roomId
} else {
selectedRoomIds + event.roomId
}
}
LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace(
leaveSpaceAction = leaveSpaceAction,
selectedRoomIds = selectedRoomIds,
)
LeaveSpaceEvents.CloseError -> {
leaveSpaceAction.value = AsyncAction.Uninitialized
}
}
}
return LeaveSpaceState(
spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.displayName,
isLastAdmin = leaveSpaceRooms.dataOrNull()?.current?.isLastAdmin == true,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.leaveSpace(
leaveSpaceAction: MutableState<AsyncAction<Unit>>,
selectedRoomIds: Collection<RoomId>,
) = launch {
runUpdatingState(leaveSpaceAction) {
leaveSpaceHandle.leave(selectedRoomIds.toList())
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class LeaveSpaceState(
val spaceName: String?,
val isLastAdmin: Boolean,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit,
) {
private val rooms = selectableSpaceRooms.dataOrNull().orEmpty().toImmutableList()
private val lastAdminRooms: ImmutableList<SelectableSpaceRoom>
private val selectableRooms: ImmutableList<SelectableSpaceRoom>
init {
val partition = rooms.partition { it.isLastAdmin }
lastAdminRooms = partition.first.toImmutableList()
selectableRooms = partition.second.toImmutableList()
}
/**
* True if we should show the quick action to select/deselect all rooms.
*/
val showQuickAction = isLastAdmin.not() && selectableRooms.isNotEmpty()
/**
* True if we should show the leave button.
*/
val showLeaveButton = isLastAdmin.not() && selectableSpaceRooms is AsyncData.Success
/**
* True if there all the selectable rooms are selected.
*/
val areAllSelected = selectableRooms.all { it.isSelected }
/**
* True if there are rooms but the user is the last admin in all of them.
*/
val hasOnlyLastAdminRoom = lastAdminRooms.isNotEmpty() && selectableRooms.isEmpty()
/**
* Number of selected rooms.
*/
val selectedRoomsCount = selectableRooms.count { it.isSelected }
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
override val values: Sequence<LeaveSpaceState>
get() = sequenceOf(
aLeaveSpaceState(),
aLeaveSpaceState(
spaceName = null,
selectableSpaceRooms = AsyncData.Success(persistentListOf()),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
displayName = "A long space name that should be truncated",
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
joinRule = JoinRule.Private,
),
isSelected = false,
),
)
)
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
joinRule = JoinRule.Private,
),
isSelected = true,
),
)
)
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
)
),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(),
isLastAdmin = true,
),
)
),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
List(10) { aSelectableSpaceRoom() }.toImmutableList()
),
leaveSpaceAction = AsyncAction.Loading,
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
List(10) { aSelectableSpaceRoom() }.toImmutableList()
),
leaveSpaceAction = AsyncAction.Failure(Exception("An error")),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Failure(Exception("An error")),
),
aLeaveSpaceState(
isLastAdmin = true,
),
)
}
fun aLeaveSpaceState(
spaceName: String? = "Space name",
isLastAdmin: Boolean = false,
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState(
spaceName = spaceName,
isLastAdmin = isLastAdmin,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction,
eventSink = { }
)
fun aSelectableSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(),
isLastAdmin: Boolean = false,
isSelected: Boolean = false,
) = SelectableSpaceRoom(
spaceRoom = spaceRoom,
isLastAdmin = isLastAdmin,
isSelected = isSelected,
)

View File

@@ -0,0 +1,363 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.space.impl.leave
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.toggleable
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
/**
* https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=3947-68767&t=GTf1cLkAf6UCQDan-0
*/
@Composable
fun LeaveSpaceView(
state: LeaveSpaceState,
onCancel: () -> Unit,
onRolesAndPermissionsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
},
containerColor = ElementTheme.colors.bgCanvasDefault,
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.consumeWindowInsets(padding)
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.weight(1f),
) {
if (state.isLastAdmin.not()) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
item {
SpaceItem(
selectableSpaceRoom = selectableSpaceRoom,
showCheckBox = state.hasOnlyLastAdminRoom.not(),
onClick = {
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
}
)
}
}
}
is AsyncData.Failure -> item {
AsyncFailure(
throwable = state.selectableSpaceRooms.error,
onRetry = {
state.eventSink(LeaveSpaceEvents.Retry)
},
)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> item {
AsyncLoading()
}
}
}
}
LeaveSpaceButtons(
showLeaveButton = state.showLeaveButton,
selectedRoomsCount = state.selectedRoomsCount,
onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace)
},
onCancel = onCancel,
// TODO enable when navigation is ready
showRolesAndPermissionsButton = false, // state.isLastAdmin,
onRolesAndPermissionsClick = onRolesAndPermissionsClick,
)
}
}
AsyncActionView(
async = state.leaveSpaceAction,
onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) },
)
}
@Composable
private fun LeaveSpaceHeader(
state: LeaveSpaceState,
onBackClick: () -> Unit,
) {
Column {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {},
)
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(
if (state.isLastAdmin) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title,
state.spaceName ?: stringResource(CommonStrings.common_space)
),
subTitle =
if (state.isLastAdmin) {
stringResource(R.string.screen_leave_space_subtitle_last_admin)
} else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.hasOnlyLastAdminRoom) {
stringResource(R.string.screen_leave_space_subtitle_only_last_admin)
} else {
stringResource(R.string.screen_leave_space_subtitle)
}
} else {
null
},
)
if (state.showQuickAction) {
if (state.areAllSelected) {
QuickActionButton(CommonStrings.action_deselect_all) {
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
}
} else {
QuickActionButton(resId = CommonStrings.action_select_all) {
state.eventSink(LeaveSpaceEvents.SelectAllRooms)
}
}
}
}
}
@Composable
private fun ColumnScope.QuickActionButton(
@StringRes resId: Int,
onClick: () -> Unit,
) {
Text(
modifier = Modifier
.align(Alignment.End)
.padding(end = 8.dp)
.clickable(onClick = onClick)
.padding(8.dp),
text = stringResource(resId),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
}
@Composable
private fun LeaveSpaceButtons(
showLeaveButton: Boolean,
selectedRoomsCount: Int,
onLeaveSpace: () -> Unit,
showRolesAndPermissionsButton: Boolean,
onRolesAndPermissionsClick: () -> Unit,
onCancel: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(16.dp)
) {
if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) {
pluralStringResource(R.plurals.screen_leave_space_submit, selectedRoomsCount, selectedRoomsCount)
} else {
stringResource(CommonStrings.action_leave_space)
}
Button(
modifier = Modifier.fillMaxWidth(),
text = text,
leadingIcon = IconSource.Vector(CompoundIcons.Leave()),
onClick = onLeaveSpace,
destructive = true,
)
}
if (showRolesAndPermissionsButton) {
Button(
text = stringResource(CommonStrings.action_go_to_roles_and_permissions),
onClick = onRolesAndPermissionsClick,
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Settings()),
)
}
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
)
}
}
@Composable
private fun SpaceItem(
selectableSpaceRoom: SelectableSpaceRoom,
showCheckBox: Boolean,
onClick: () -> Unit,
) {
val room = selectableSpaceRoom.spaceRoom
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 66.dp)
.toggleable(
value = selectableSpaceRoom.isSelected,
role = Role.Checkbox,
enabled = selectableSpaceRoom.isLastAdmin.not(),
onValueChange = { onClick() }
)
.clickable(
enabled = selectableSpaceRoom.isLastAdmin.not(),
// TODO
onClickLabel = null,
role = Role.Checkbox,
onClick = onClick,
),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom),
avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(),
)
Column(
modifier = Modifier.weight(1f),
) {
Text(
modifier = Modifier
.padding(end = 16.dp),
text = room.displayName,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (room.joinRule == JoinRule.Private) {
// Picto for private
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.LockSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
} else if (room.worldReadable) {
// Picto for world readable
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.Public(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
// Number of members
val membersCount = pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
)
val subTitle = if (selectableSpaceRoom.isLastAdmin) {
stringResource(R.string.screen_leave_space_last_admin_info, membersCount)
} else {
membersCount
}
Text(
modifier = Modifier.padding(end = 16.dp),
text = subTitle,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (showCheckBox) {
Checkbox(
checked = selectableSpaceRoom.isSelected,
onCheckedChange = null,
enabled = selectableSpaceRoom.isLastAdmin.not(),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun LeaveSpaceViewPreview(
@PreviewParameter(LeaveSpaceStateProvider::class) state: LeaveSpaceState,
) = ElementPreview {
LeaveSpaceView(
state = state,
onCancel = {},
onRolesAndPermissionsClick = {},
)
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
data class SelectableSpaceRoom(
val spaceRoom: SpaceRoom,
val isLastAdmin: Boolean,
val isSelected: Boolean,
)

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
sealed interface SpaceEvents {
data object LoadMore : SpaceEvents
data class Join(val spaceRoom: SpaceRoom) : SpaceEvents
data object ClearFailures : SpaceEvents
data class AcceptInvite(val spaceRoom: SpaceRoom) : SpaceEvents
data class DeclineInvite(val spaceRoom: SpaceRoom) : SpaceEvents
data class ShowTopicViewer(val topic: String) : SpaceEvents
data object HideTopicViewer : SpaceEvents
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesNode(SpaceFlowScope::class)
@AssistedInject
class SpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SpacePresenter,
private val matrixClient: MatrixClient,
private val spaceRoomList: SpaceRoomList,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, viaParameters: List<String>)
fun navigateToSpaceSettings()
fun navigateToRoomMemberList()
fun startLeaveSpaceFlow()
}
private val callback: Callback = callback()
private fun onShareRoom(context: Context) = lifecycleScope.launch {
matrixClient.getRoom(spaceRoomList.roomId)?.use { room ->
room.getPermalink()
.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(CommonStrings.common_share_space),
text = permalink,
noActivityFoundMessage = context.getString(R.string.error_no_compatible_app_found)
)
}
.onFailure {
Timber.e(it)
}
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
SpaceView(
state = state,
onBackClick = ::navigateUp,
onLeaveSpaceClick = {
callback.startLeaveSpaceFlow()
},
onRoomClick = { spaceRoom ->
callback.navigateToRoom(spaceRoom.roomId, spaceRoom.via)
},
onDetailsClick = {
callback.navigateToSpaceSettings()
},
onShareSpace = {
onShareRoom(context)
},
onViewMembersClick = {
callback.navigateToRoomMemberList()
},
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInviteSuccess = { roomId ->
callback.navigateToRoom(roomId, emptyList())
},
onDeclineInviteSuccess = { roomId ->
// No action needed
},
modifier = Modifier
)
},
modifier = modifier
)
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@Inject
class SpacePresenter(
private val spaceRoomList: SpaceRoomList,
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
private val joinRoom: JoinRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> {
private var children by mutableStateOf<ImmutableList<SpaceRoom>>(persistentListOf())
@Composable
override fun present(): SpaceState {
LaunchedEffect(Unit) {
paginate()
spaceRoomList.spaceRoomsFlow.collect { children = it.toImmutableList() }
}
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
val seenSpaceInvites by remember {
seenInvitesStore.seenRoomIds().map { it.toImmutableSet() }
}.collectAsState(persistentSetOf())
val localCoroutineScope = rememberCoroutineScope()
val hasMoreToLoad by remember {
spaceRoomList.paginationStatusFlow.mapState { status ->
when (status) {
is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad
SpaceRoomList.PaginationStatus.Loading -> true
}
}
}.collectAsState()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap<RoomId, AsyncAction<Unit>>()) }
var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) }
LaunchedEffect(children) {
// Remove joined children from the join actions
val joinedChildren = children
.filter { it.state == CurrentUserMembership.JOINED }
.map { it.roomId }
setJoinActions(joinActions - joinedChildren)
}
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
fun handleEvent(event: SpaceEvents) {
when (event) {
SpaceEvents.LoadMore -> localCoroutineScope.paginate()
is SpaceEvents.Join -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions)
}
SpaceEvents.ClearFailures -> {
val failedActions = joinActions
.filterValues { it is AsyncAction.Failure }
.mapValues { AsyncAction.Uninitialized }
setJoinActions(joinActions + failedActions)
}
is SpaceEvents.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(event.spaceRoom.toInviteData())
)
}
is SpaceEvents.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(invite = event.spaceRoom.toInviteData(), shouldConfirm = true, blockUser = false)
)
}
SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden
is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic)
}
}
return SpaceState(
currentSpace = currentSpace.getOrNull(),
children = children,
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.joinRoom(
spaceRoom: SpaceRoom,
joinActions: Map<RoomId, AsyncAction<Unit>>,
setJoinActions: (Map<RoomId, AsyncAction<Unit>>) -> Unit
) = launch {
setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Loading))
joinRoom.invoke(
roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(),
serverNames = spaceRoom.via,
trigger = JoinedRoom.Trigger.SpaceHierarchy,
).onFailure {
setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it)))
}
}
private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate()
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
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.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
data class SpaceState(
val currentSpace: SpaceRoom?,
val children: ImmutableList<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean,
val joinActions: ImmutableMap<RoomId, AsyncAction<Unit>>,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val topicViewerState: TopicViewerState,
val eventSink: (SpaceEvents) -> Unit
) {
fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading
val hasAnyFailure: Boolean = joinActions.values.any {
it is AsyncAction.Failure
}
}
@Immutable
sealed interface TopicViewerState {
data object Hidden : TopicViewerState
data class Shown(val topic: String) : TopicViewerState
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
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.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
override val values: Sequence<SpaceState>
get() = sequenceOf(
aSpaceState(),
aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Public)),
aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Restricted(persistentListOf()))),
aSpaceState(children = aListOfSpaceRooms()),
aSpaceState(
parentSpace = aParentSpace(),
children = aListOfSpaceRooms(),
joiningRooms = setOf(RoomId("!spaceId0:example.com")),
hasMoreToLoad = false
),
aSpaceState(
topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()),
),
// Add other states here
)
}
fun aSpaceState(
parentSpace: SpaceRoom? = aParentSpace(),
children: List<SpaceRoom> = emptyList(),
seenSpaceInvites: Set<RoomId> = emptySet(),
joiningRooms: Set<RoomId> = emptySet(),
joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading },
hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = true,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
topicViewerState: TopicViewerState = TopicViewerState.Hidden,
eventSink: (SpaceEvents) -> Unit = { },
) = SpaceState(
currentSpace = parentSpace,
children = children.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
eventSink = eventSink,
)
private fun aParentSpace(
joinRule: JoinRule? = null,
): SpaceRoom {
return aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
joinRule = joinRule,
roomId = RoomId("!spaceId0:example.com"),
topic = "Space description goes here. " + LoremIpsum(20).values.first(),
)
}
private fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf(
aSpaceRoom(
roomId = RoomId("!spaceId0:example.com"),
state = null,
),
aSpaceRoom(
roomId = RoomId("!spaceId1:example.com"),
state = CurrentUserMembership.JOINED,
),
aSpaceRoom(
roomId = RoomId("!spaceId2:example.com"),
state = CurrentUserMembership.INVITED,
),
)
}

View File

@@ -0,0 +1,427 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
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.JoinButton
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
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpaceView(
state: SpaceState,
onBackClick: () -> Unit,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onShareSpace: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onDetailsClick: () -> Unit,
onViewMembersClick: () -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
SpaceViewTopBar(
currentSpace = state.currentSpace,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace,
onDetailsClick = onDetailsClick,
onViewMembersClick = onViewMembersClick,
)
},
content = { padding ->
Box(
modifier = Modifier.padding(padding)
) {
SpaceViewContent(
state = state,
onRoomClick = onRoomClick,
onTopicClick = { topic ->
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
}
)
JoinRoomFailureEffect(
hasAnyFailure = state.hasAnyFailure,
eventSink = state.eventSink
)
acceptDeclineInviteView()
}
},
)
if (state.topicViewerState is TopicViewerState.Shown) {
TopicViewerBottomSheet(
topicViewerState = state.topicViewerState,
onDismiss = {
state.eventSink(SpaceEvents.HideTopicViewer)
}
)
}
}
@Composable
private fun JoinRoomFailureEffect(
hasAnyFailure: Boolean,
eventSink: (SpaceEvents) -> Unit,
) {
val asyncIndicatorState = rememberAsyncIndicatorState()
val updatedEventSink by rememberUpdatedState(eventSink)
AsyncIndicatorHost(modifier = Modifier, asyncIndicatorState)
LaunchedEffect(hasAnyFailure) {
if (hasAnyFailure) {
asyncIndicatorState.enqueue {
AsyncIndicator.Failure(text = stringResource(CommonStrings.common_something_went_wrong))
}
delay(AsyncIndicator.DURATION_SHORT)
updatedEventSink(SpaceEvents.ClearFailures)
} else {
asyncIndicatorState.clear()
}
}
}
@Composable
private fun TopicViewerBottomSheet(
topicViewerState: TopicViewerState.Shown,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
SimpleModalBottomSheet(
title = stringResource(CommonStrings.common_description),
onDismiss = onDismiss,
modifier = modifier
) {
ClickableLinkText(
text = topicViewerState.topic,
interactionSource = remember { MutableInteractionSource() },
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
@Composable
private fun SpaceViewContent(
state: SpaceState,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
val currentSpace = state.currentSpace
if (currentSpace != null) {
item {
SpaceHeaderView(
avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader),
name = currentSpace.displayName,
topic = currentSpace.topic,
topicMaxLines = 2,
visibility = currentSpace.visibility,
heroes = currentSpace.heroes.toImmutableList(),
numberOfMembers = currentSpace.numJoinedMembers,
onTopicClick = onTopicClick
)
}
item {
HorizontalDivider()
}
}
itemsIndexed(
items = state.children,
key = { _, spaceRoom -> spaceRoom.roomId }
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = {
onRoomClick(spaceRoom)
},
onLongClick = {
// TODO
},
trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
},
bottomAction = spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
}
)
)
if (index != state.children.lastIndex) {
HorizontalDivider()
}
}
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
private fun SpaceViewTopBar(
currentSpace: SpaceRoom?,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onDetailsClick: () -> Unit,
onShareSpace: () -> Unit,
onViewMembersClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {
if (currentSpace != null) {
val roundedCornerShape = RoundedCornerShape(8.dp)
SpaceAvatarAndNameRow(
name = currentSpace.displayName,
avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom),
modifier = Modifier
.clip(roundedCornerShape)
// TODO enable when screen ready for space
.clickable(enabled = false, onClick = onDetailsClick)
)
}
},
actions = {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onShareSpace()
},
text = { Text(stringResource(id = CommonStrings.action_share)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onViewMembersClick()
},
text = { Text(stringResource(id = CommonStrings.screen_space_menu_action_members)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.User(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onLeaveSpaceClick()
},
text = {
Text(
text = stringResource(id = CommonStrings.action_leave_space),
color = ElementTheme.colors.textCriticalPrimary,
)
},
leadingIcon = {
Icon(
imageVector = CompoundIcons.Leave(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
)
}
},
)
}
@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_space_name),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { name == null },
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
private fun SpaceRoom.trailingAction(
isCurrentlyJoining: Boolean,
onClick: () -> Unit
): @Composable (() -> Unit)? {
return when (state) {
null, CurrentUserMembership.LEFT -> {
{
JoinButton(
showProgress = isCurrentlyJoining,
onClick = onClick,
)
}
}
else -> null
}
}
private fun SpaceRoom.inviteButtons(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
): @Composable (() -> Unit)? {
return when (state) {
CurrentUserMembership.INVITED -> {
@Composable {
InviteButtonsRowMolecule(
onAcceptClick = onAcceptClick,
onDeclineClick = onDeclineClick,
)
}
}
else -> null
}
}
@PreviewsDayNight
@Composable
internal fun SpaceViewPreview(
@PreviewParameter(SpaceStateProvider::class) state: SpaceState
) = ElementPreview {
SpaceView(
state = state,
onRoomClick = {},
onShareSpace = {},
onLeaveSpaceClick = {},
acceptDeclineInviteView = {},
onDetailsClick = {},
onViewMembersClick = {},
onBackClick = {},
)
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.settings
sealed interface SpaceSettingsEvents

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
@ContributesNode(SpaceFlowScope::class)
@AssistedInject
class SpaceSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SpaceSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun closeSettings()
fun navigateToSpaceInfo()
fun navigateToSpaceMembers()
fun navigateToRolesAndPermissions()
fun navigateToSecurityAndPrivacy()
fun startLeaveSpaceFlow()
}
private val callback: Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
@Composable
override fun View(modifier: Modifier) {
val state by stateFlow.collectAsState()
SpaceSettingsView(
state = state,
modifier = modifier,
onSpaceInfoClick = callback::navigateToSpaceInfo,
onBackClick = callback::closeSettings,
onMembersClick = callback::navigateToSpaceMembers,
onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions,
onSecurityAndPrivacyClick = callback::navigateToSecurityAndPrivacy,
onLeaveSpaceClick = callback::startLeaveSpaceFlow,
)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
@Inject
class SpaceSettingsPresenter(
private val room: JoinedRoom,
) : Presenter<SpaceSettingsState> {
@Composable
override fun present(): SpaceSettingsState {
val roomInfo by room.roomInfoFlow.collectAsState()
val isUserAdmin = room.isOwnUserAdmin()
return SpaceSettingsState(
roomId = room.roomId,
name = roomInfo.name.orEmpty(),
canonicalAlias = roomInfo.canonicalAlias,
avatarUrl = roomInfo.avatarUrl,
memberCount = roomInfo.activeMembersCount,
showRolesAndPermissions = isUserAdmin,
showSecurityAndPrivacy = isUserAdmin,
eventSink = {},
)
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.settings
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
data class SpaceSettingsState(
val roomId: RoomId,
val name: String,
val canonicalAlias: RoomAlias?,
val avatarUrl: String?,
val memberCount: Long,
val showRolesAndPermissions: Boolean,
val showSecurityAndPrivacy: Boolean,
val eventSink: (SpaceSettingsEvents) -> Unit
)

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.settings
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
open class SpaceSettingsStateProvider : PreviewParameterProvider<SpaceSettingsState> {
override val values: Sequence<SpaceSettingsState>
get() = sequenceOf(
aSpaceSettingsState(),
aSpaceSettingsState(alias = null),
aSpaceSettingsState(showSecurityAndPrivacy = true),
aSpaceSettingsState(showRolesAndPermissions = true),
)
}
fun aSpaceSettingsState(
roomId: RoomId = RoomId("!aRoomId:element.io"),
name: String = "Space name",
alias: RoomAlias? = RoomAlias("#spacename:element.io"),
avatarUrl: String? = null,
memberCount: Long = 100,
showRolesAndPermissions: Boolean = false,
showSecurityAndPrivacy: Boolean = false,
eventSink: (SpaceSettingsEvents) -> Unit = {},
) = SpaceSettingsState(
roomId = roomId,
name = name,
canonicalAlias = alias,
avatarUrl = avatarUrl,
memberCount = memberCount,
showRolesAndPermissions = showRolesAndPermissions,
showSecurityAndPrivacy = showSecurityAndPrivacy,
eventSink = eventSink,
)

View File

@@ -0,0 +1,232 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
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.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
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.ui.strings.CommonStrings
@Composable
fun SpaceSettingsView(
state: SpaceSettingsState,
onBackClick: () -> Unit,
onSpaceInfoClick: () -> Unit,
onMembersClick: () -> Unit,
onRolesAndPermissionsClick: () -> Unit,
onSecurityAndPrivacyClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
SpaceSettingsTopBar(onBackClick = onBackClick)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
SpaceInfoSection(
roomId = state.roomId,
name = state.name,
avatarUrl = state.avatarUrl,
canonicalAlias = state.canonicalAlias?.value,
onSpaceInfoClick = onSpaceInfoClick,
)
Section(isVisible = state.showSecurityAndPrivacy, content = {
SecurityAndPrivacyItem(
onClick = onSecurityAndPrivacyClick
)
})
Section(content = {
MembersItem(state.memberCount, onClick = onMembersClick)
if (state.showRolesAndPermissions) {
RolesAndPermissionsItem(onClick = onRolesAndPermissionsClick)
}
})
Section(content = {
LeaveSpaceItem(
onClick = onLeaveSpaceClick
)
})
}
}
}
@Composable
private fun SpaceInfoSection(
roomId: RoomId,
name: String,
avatarUrl: String?,
canonicalAlias: String?,
onSpaceInfoClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSpaceInfoClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = AvatarData(roomId.value, name, avatarUrl, AvatarSize.SpaceListItem),
avatarType = AvatarType.Space(),
contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_avatar) },
)
Spacer(Modifier.width(16.dp))
Column {
Text(
text = name,
style = ElementTheme.typography.fontHeadingMdRegular,
color = ElementTheme.colors.textPrimary,
)
if (canonicalAlias != null) {
Text(
text = canonicalAlias,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@Composable
private fun Section(
modifier: Modifier = Modifier,
isVisible: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
if (isVisible) {
PreferenceCategory(content = content, modifier = modifier)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SpaceSettingsTopBar(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
titleStr = stringResource(CommonStrings.common_settings),
navigationIcon = { BackButton(onClick = onBackClick) },
modifier = modifier,
)
}
@Composable
private fun SecurityAndPrivacyItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_space_settings_security_and_privacy)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun MembersItem(
memberCount: Long,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun RolesAndPermissionsItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_space_settings_roles_and_permissions)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun LeaveSpaceItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.action_leave_space))
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())),
style = ListItemStyle.Destructive,
onClick = onClick,
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun SpaceSettingsViewPreview(
@PreviewParameter(SpaceSettingsStateProvider::class) state: SpaceSettingsState
) = ElementPreview {
SpaceSettingsView(
state = state,
onBackClick = {},
onSpaceInfoClick = {},
onMembersClick = {},
onRolesAndPermissionsClick = {},
onSecurityAndPrivacyClick = {},
onLeaveSpaceClick = {},
modifier = Modifier,
)
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Ролі і дазволы"</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_leave_space">"Напускане на пространството"</string>
<string name="screen_space_settings_roles_and_permissions">"Роли и разрешения"</string>
<string name="screen_space_settings_security_and_privacy">"Защита и поверителност"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Správce)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Opustit %1$d místnost a prostor"</item>
<item quantity="few">"Opustit %1$d místnosti a prostor"</item>
<item quantity="other">"Opustit %1$d místností a prostor"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Tím budete také odstraněni ze všech místností v tomto prostoru."</string>
<string name="screen_leave_space_subtitle_last_admin">"Než budete moci odejít, musíte pro tento prostor přiřadit jiného správce."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Z následujících místností nebudete odstraněni, protože jste jediným administrátorem:"</string>
<string name="screen_leave_space_title">"Opustit %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Jste jediným administrátorem pro %1$s"</string>
<string name="screen_space_settings_leave_space">"Opustit prostor"</string>
<string name="screen_space_settings_roles_and_permissions">"Role a oprávnění"</string>
<string name="screen_space_settings_security_and_privacy">"Zabezpečení a soukromí"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Gweinyddwr)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="zero">"Gadael %1$d ystafelloedd a gofodau"</item>
<item quantity="one">"Gadael %1$d ystafell a gofod"</item>
<item quantity="two">"Gadael %1$d ystafell a gofod"</item>
<item quantity="few">"Gadael %1$d ystafell a gofod"</item>
<item quantity="many">"Gadael %1$d ystafell a gofod"</item>
<item quantity="other">"Gadael %1$d ystafell a gofod"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Dewiswch yr ystafelloedd yr hoffech chi eu gadael nad chi yw\'r unig weinyddwr ar eu cyfer:"</string>
<string name="screen_leave_space_title">"Gadael %1$s ?"</string>
<string name="screen_space_settings_leave_space">"Gadael y gofod"</string>
<string name="screen_space_settings_roles_and_permissions">"Rolau a chaniatâd"</string>
<string name="screen_space_settings_security_and_privacy">"Diogelwch a phreifatrwydd"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Forlad %1$d rum og gruppe"</item>
<item quantity="other">"Forlad %1$d rum og Grupper"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Vælg de rum, du vil forlade, som du ikke er den eneste administrator for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Du skal tildele en anden administrator til denne gruppe, før du kan forlade den."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:"</string>
<string name="screen_leave_space_title">"Forlad %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Du er den eneste administrator for %1$s"</string>
<string name="screen_space_settings_leave_space">"Forlad gruppe"</string>
<string name="screen_space_settings_roles_and_permissions">"Roller og tilladelser"</string>
<string name="screen_space_settings_security_and_privacy">"Sikkerhed og privatliv"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d Chat und Space verlassen"</item>
<item quantity="other">"%1$d Chats und Space verlassen"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Dadurch wirst du auch aus allen Chats in diesem Space entfernt."</string>
<string name="screen_leave_space_subtitle_last_admin">"Du musst einen anderen Admin für diesen Space zuweisen, bevor du ihn verlassen kannst."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:"</string>
<string name="screen_leave_space_title">"%1$s verlassen?"</string>
<string name="screen_leave_space_title_last_admin">"Du bist der einzige Administrator für %1$s"</string>
<string name="screen_space_settings_leave_space">"Space verlassen"</string>
<string name="screen_space_settings_roles_and_permissions">"Rollen und Berechtigungen"</string>
<string name="screen_space_settings_security_and_privacy">"Sicherheit &amp; Datenschutz"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Ρόλοι και δικαιώματα"</string>
<string name="screen_space_settings_security_and_privacy">"Ασφάλεια &amp; απόρρητο"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Roles y permisos"</string>
<string name="screen_space_settings_security_and_privacy">"Seguridad y privacidad"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Peakasutaja)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Lahku %1$d-st jututoast ja kogukonnast"</item>
<item quantity="other">"Lahku %1$d-st jututoast ja kogukonnast"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Sellega eemaldad end ka kõikidest antud kogukonna jututubadest."</string>
<string name="screen_leave_space_subtitle_last_admin">"Enne lahkumist pead sa selle kogukonna jaoks lisama vähemalt ühe täiendava peakasutaja."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:"</string>
<string name="screen_leave_space_title">"Kas lahkud %1$s kogukonnast?"</string>
<string name="screen_leave_space_title_last_admin">"Sa oled siin ainus peakasutaja: %1$s"</string>
<string name="screen_space_settings_leave_space">"Lahku kogukonnast"</string>
<string name="screen_space_settings_roles_and_permissions">"Rollid ja õigused"</string>
<string name="screen_space_settings_security_and_privacy">"Turvalisus ja privaatsus"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Rolak eta baimenak"</string>
<string name="screen_space_settings_security_and_privacy">"Segurtasuna eta pribatutasuna"</string>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (مدیر)"</string>
<string name="screen_leave_space_subtitle_last_admin">"پیش از ترک باید مدیری دیگر به این فضا تخصیص دهید."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"از اتاق(های) زیر برداشته نخواهید شد؛ چرا که تنها مدیر هستید:"</string>
<string name="screen_leave_space_title">"ترک %1$s؟"</string>
<string name="screen_leave_space_title_last_admin">"تنها مدیر %1$s هستید"</string>
<string name="screen_space_settings_leave_space">"ترک فضا"</string>
<string name="screen_space_settings_roles_and_permissions">"نقش‌ها و اجازه‌ها"</string>
<string name="screen_space_settings_security_and_privacy">"امنیت و محرمانگی"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Ylläpitäjä)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Poistu %1$d huoneesta ja tilasta"</item>
<item quantity="other">"Poistu %1$d huoneesta ja tilasta"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Tämä poistaa sinut myös kaikista tämän tilan huoneista."</string>
<string name="screen_leave_space_subtitle_last_admin">"Sinun on valittava tälle tilalle toinen ylläpitäjä ennen kuin voit poistua."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Sinua ei poisteta seuraavista huoneista, koska olet ainoa ylläpitäjä:"</string>
<string name="screen_leave_space_title">"Haluatko poistua tilasta %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Olet ainoa ylläpitäjä tilassa %1$s"</string>
<string name="screen_space_settings_leave_space">"Poistu tilasta"</string>
<string name="screen_space_settings_roles_and_permissions">"Roolit ja oikeudet"</string>
<string name="screen_space_settings_security_and_privacy">"Turvallisuus ja yksityisyys"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Quitter %1$d salon et lespace"</item>
<item quantity="other">"Quitter %1$d salons et lespace"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Sélectionnez les salons que vous souhaitez quitter et dont vous nêtes pas le seul administrateur:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Vous devez désigner un autre administrateur pour cet espace avant de pouvoir partir."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:"</string>
<string name="screen_leave_space_title">"Quitter %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Vous êtes le seul administrateur de %1$s"</string>
<string name="screen_space_settings_leave_space">"Quitter lespace"</string>
<string name="screen_space_settings_roles_and_permissions">"Rôles &amp; autorisations"</string>
<string name="screen_space_settings_security_and_privacy">"Sécurité &amp; confidentialité"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Adminisztrátor)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d szoba és tér elhagyása"</item>
<item quantity="other">"%1$d szoba és tér elhagyása"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Ez a tér összes szobájából is eltávolítja."</string>
<string name="screen_leave_space_subtitle_last_admin">"Mielőtt elhagyhatná ezt a teret, ki kell jelölnie egy másik adminisztrátort."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:"</string>
<string name="screen_leave_space_title">"Kilép innen: %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Ön az egyetlen adminisztrátor itt: %1$s"</string>
<string name="screen_space_settings_leave_space">"Tér elhagyása"</string>
<string name="screen_space_settings_roles_and_permissions">"Szerepkörök és jogosultságok"</string>
<string name="screen_space_settings_security_and_privacy">"Biztonság és adatvédelem"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Peran dan perizinan"</string>
<string name="screen_space_settings_security_and_privacy">"Keamanan &amp; privasi"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Amministratore)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Lascia %1$d stanza e spazio"</item>
<item quantity="other">"Lascia %1$d stanze e spazi"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Seleziona le stanze che desideri abbandonare e di cui non sei l\'unico amministratore:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Prima di poter uscire, devi assegnare un altro amministratore a questo spazio."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Non verrai rimosso dalle seguenti stanze perché sei l\'unico amministratore:"</string>
<string name="screen_leave_space_title">"Uscire da %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Sei l\'unico amministratore di %1$s"</string>
<string name="screen_space_settings_leave_space">"Esci dallo spazio"</string>
<string name="screen_space_settings_roles_and_permissions">"Ruoli e autorizzazioni"</string>
<string name="screen_space_settings_security_and_privacy">"Sicurezza e privacy"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"როლები და ნებართვები"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"역할 및 권한"</string>
<string name="screen_space_settings_security_and_privacy">"보안 및 개인정보 보호"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Forlat %1$d rom og område"</item>
<item quantity="other">"Forlat %1$d rom og område"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Velg rommene du vil forlate, som du ikke er den eneste administratoren for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Du må tildele en annen administrator for dette området før du kan forlate det."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Du vil ikke bli fjernet fra følgende rom fordi du er den eneste administratoren:"</string>
<string name="screen_leave_space_title">"Forlat %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Du er den eneste administratoren for %1$s"</string>
<string name="screen_space_settings_leave_space">"Forlat område"</string>
<string name="screen_space_settings_roles_and_permissions">"Roller og tillatelser"</string>
<string name="screen_space_settings_security_and_privacy">"Sikkerhet og personvern"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Rollen en rechten"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Administrator)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Opuść %1$d pokój i przestrzeń"</item>
<item quantity="few">"Opuść %1$d pokoje i przestrzeń"</item>
<item quantity="many">"Opuść %1$d pokojów i przestrzeń"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Wybierz pokoje, które chcesz opuścić, a których nie jesteś jedynym administratorem:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Aby opuścić tę przestrzeń, musisz przypisać do niej innego administratora."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Nie zostaniesz usunięty z następujących pokoi, ponieważ jesteś ich jedynym administratorem:"</string>
<string name="screen_leave_space_title">"Opuścić %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Jesteś jedynym administratorem %1$s"</string>
<string name="screen_space_settings_leave_space">"Opuść przestrzeń"</string>
<string name="screen_space_settings_roles_and_permissions">"Role i uprawnienia"</string>
<string name="screen_space_settings_security_and_privacy">"Bezpieczeństwo i prywatność"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Administrador)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Sair de %1$d sala e espaço"</item>
<item quantity="other">"Sair de %1$d salas e do espaço"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Selecione as salas que gostaria de sair nas quais você não é o único administrador:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Você precisa atribuir outro administrador para este espaço antes de sair."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Você não será removido das seguintes salas porque você é o único administrador:"</string>
<string name="screen_leave_space_title">"Sair de %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Você é o único administrador de %1$s"</string>
<string name="screen_space_settings_leave_space">"Sair do espaço"</string>
<string name="screen_space_settings_roles_and_permissions">"Cargos e permissões"</string>
<string name="screen_space_settings_security_and_privacy">"Segurança e privacidade"</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Sair do espaço e de %1$d sala"</item>
<item quantity="other">"Sair do espaço e de %1$d salas"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Também irás sair de todas as salas deste espaço."</string>
<string name="screen_leave_space_title">"Sair de %1$s?"</string>
<string name="screen_space_settings_leave_space">"Sair do espaço"</string>
<string name="screen_space_settings_roles_and_permissions">"Cargos e permissões"</string>
<string name="screen_space_settings_security_and_privacy">"Segurança e privacidade"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Părăsiți %1$d cameră și spațiul"</item>
<item quantity="few">"Părăsiți %1$d camere și spațiul"</item>
<item quantity="other">"Părăsiți %1$d camere și spațiul"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Selectați camerele pe care doriți să le părăsiți și în care nu sunteți singurul administrator:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Trebuie să desemnați un alt administrator pentru acest spațiu înainte de a-l părăsi."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Nu veți părăsi următoarele camere deoarece sunteți singurul administrator:"</string>
<string name="screen_leave_space_title">"Părăsiți %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Sunteți singurul administrator pentru %1$s"</string>
<string name="screen_space_settings_leave_space">"Părăsiți spațiul"</string>
<string name="screen_space_settings_roles_and_permissions">"Roluri și permisiuni"</string>
<string name="screen_space_settings_security_and_privacy">"Securitate &amp; confidențialitate"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Администратор)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Покинуть %1$d комнату и пространство"</item>
<item quantity="few">"Покинуть %1$d комнат и пространство"</item>
<item quantity="many">"Покинуть %1$d комнат и пространство"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Выберите комнаты, которые вы хотите покинуть и в которых вы не являетесь единственным администратором:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Прежде чем покинуть это пространство, вам необходимо назначить другого администратора."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:"</string>
<string name="screen_leave_space_title">"Выйти из %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Вы единственный администратор для %1$s"</string>
<string name="screen_space_settings_leave_space">"Покинуть пространство"</string>
<string name="screen_space_settings_roles_and_permissions">"Роли и разрешения"</string>
<string name="screen_space_settings_security_and_privacy">"Безопасность и конфиденциальность"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Správca)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Opustiť %1$d miestnosť a priestor"</item>
<item quantity="few">"Opustiť %1$d miestnosti a priestory"</item>
<item quantity="other">"Opustiť %1$d miestností a priestorov"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Vyberte miestnosti, ktoré chcete opustiť a pre ktoré nie ste jediným správcom:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Pred odchodom musíte pre tento priestor určiť iného správcu."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Z nasledujúcich miestností nebudete odstránený/á, pretože ste jediným správcom:"</string>
<string name="screen_leave_space_title">"Opustiť %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Ste jediným administrátorom pre %1$s"</string>
<string name="screen_space_settings_leave_space">"Opustiť priestor"</string>
<string name="screen_space_settings_roles_and_permissions">"Roly a povolenia"</string>
<string name="screen_space_settings_security_and_privacy">"Bezpečnosť a súkromie"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Roller och behörigheter"</string>
<string name="screen_space_settings_security_and_privacy">"Säkerhet och sekretess"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Roller ve izinler"</string>
<string name="screen_space_settings_security_and_privacy">"Güvenlik ve gizlilik"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Ролі та дозволи"</string>
<string name="screen_space_settings_security_and_privacy">"Безпека й приватність"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"کردارہا اور اجازتیں"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_settings_roles_and_permissions">"Rollar va ruxsatlar"</string>
<string name="screen_space_settings_security_and_privacy">"Xavfsizlik va maxfiylik"</string>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s管理員"</string>
<plurals name="screen_leave_space_submit">
<item quantity="other">"離開 %1$d 個聊天室與空間"</item>
</plurals>
<string name="screen_leave_space_subtitle">"這也會將您從此空間中的所有聊天室移除。"</string>
<string name="screen_leave_space_subtitle_last_admin">"您必須為此空間另外指定一位管理員後才能離開。"</string>
<string name="screen_leave_space_subtitle_only_last_admin">"您不會被從以下聊天室移除,因為您是唯一的管理員:"</string>
<string name="screen_leave_space_title">"離開 %1$s"</string>
<string name="screen_leave_space_title_last_admin">"您是 %1$s 唯一的管理員"</string>
<string name="screen_space_settings_leave_space">"離開空間"</string>
<string name="screen_space_settings_roles_and_permissions">"角色與權限"</string>
<string name="screen_space_settings_security_and_privacy">"安全與隱私"</string>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (管理员)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="other">"离开 %1$d 个房间和空间"</item>
</plurals>
<string name="screen_leave_space_subtitle">"选择您想要离开且您不是其唯一管理员的房间:"</string>
<string name="screen_leave_space_subtitle_last_admin">"您需要为该空间指定另一位管理员才能离开。"</string>
<string name="screen_leave_space_subtitle_only_last_admin">"您不会从以下房间中被移除,因为您是唯一的管理员:"</string>
<string name="screen_leave_space_title">"离开%1$s"</string>
<string name="screen_leave_space_title_last_admin">"您是 %1$s 的唯一管理员"</string>
<string name="screen_space_settings_leave_space">"离开空间"</string>
<string name="screen_space_settings_roles_and_permissions">"角色与权限"</string>
<string name="screen_space_settings_security_and_privacy">"安全与隐私"</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item>
<item quantity="other">"Leave %1$d rooms and space"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"You need to assign another admin for this space before you can leave."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
<string name="screen_space_settings_leave_space">"Leave space"</string>
<string name="screen_space_settings_roles_and_permissions">"Roles &amp; permissions"</string>
<string name="screen_space_settings_security_and_privacy">"Security &amp; privacy"</string>
</resources>

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultSpaceEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins ->
SpaceFlowNode(
buildContext = buildContext,
plugins = plugins,
spaceService = FakeSpaceService(
spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) }
),
room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory
)
}
val callback = object : SpaceEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError()
override fun navigateToRoomMemberList() = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
inputs = nodeInputs,
callback = callback,
)
assertThat(result).isInstanceOf(SpaceFlowNode::class.java)
assertThat(result.plugins).contains(nodeInputs)
assertThat(result.plugins).contains(callback)
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.di
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.AssistedNodeFactory
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import kotlin.reflect.KClass
class FakeSpaceFlowGraph : SpaceFlowGraph {
object Factory : SpaceFlowGraph.Factory {
override fun create(spaceRoomList: SpaceRoomList): SpaceFlowGraph {
return FakeSpaceFlowGraph()
}
}
override fun nodeFactories(): Map<KClass<out Node>, AssistedNodeFactory<*>> {
return emptyMap()
}
}

View File

@@ -0,0 +1,253 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_SPACE_NAME
import io.element.android.libraries.matrix.test.spaces.FakeLeaveSpaceHandle
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LeaveSpacePresenterTest {
private val aSpace = aSpaceRoom(
roomId = A_SPACE_ID,
displayName = A_SPACE_NAME,
)
@Test
fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
),
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.isLastAdmin).isFalse()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - fail to load rooms`() = runTest {
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.failure(AN_EXCEPTION) },
)
)
presenter.test {
val state = awaitItem()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(2)
val stateError = awaitItem()
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
// Retry
stateError.eventSink(LeaveSpaceEvents.Retry)
skipItems(1)
val stateLoadingAgain = awaitItem()
assertThat(stateLoadingAgain.selectableSpaceRooms.isLoading()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - current space name and is last admin`() = runTest {
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpace, isLastAdmin = true))) },
)
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
assertThat(finalState.isLastAdmin).isTrue()
// The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty()
}
}
@Test
fun `present - direct rooms are filtered out`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> { Result.success(Unit) }
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = {
Result.success(
listOf(
aLeaveSpaceRoom(spaceRoom = aSpace),
aLeaveSpaceRoom(
spaceRoom = aSpaceRoom(roomId = A_ROOM_ID, isDirect = false)
),
aLeaveSpaceRoom(
spaceRoom = aSpaceRoom(roomId = A_ROOM_ID_2, isDirect = true)
),
aLeaveSpaceRoom(
spaceRoom = aSpaceRoom(roomId = A_ROOM_ID_3, isDirect = null)
),
)
)
},
leaveResult = leaveResult,
)
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
skipItems(2)
val finalState = awaitItem()
// The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!.map { it.spaceRoom.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_3)
assertThat(finalState.selectedRoomsCount).isEqualTo(2)
// Leaving the space will not include the DM
finalState.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateLeft = awaitItem()
assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue()
leaveResult.assertions().isCalledOnce().with(
value(listOf(A_ROOM_ID, A_ROOM_ID_3))
)
}
}
@Test
fun `present - leave space and sub rooms`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> { Result.success(Unit) }
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = {
Result.success(
listOf(
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastAdmin = false),
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastAdmin = true),
)
)
},
leaveResult = leaveResult,
)
)
presenter.test {
skipItems(3)
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.isLastAdmin).isFalse()
val data = state.selectableSpaceRooms.dataOrNull()!!
assertThat(data.size).isEqualTo(2)
// Only one room is selectable as the user is the last admin in the other one
val room1 = data[0]
assertThat(room1.spaceRoom.roomId).isEqualTo(A_ROOM_ID)
assertThat(room1.isSelected).isTrue()
assertThat(room1.isLastAdmin).isFalse()
val room2 = data[1]
assertThat(room2.spaceRoom.roomId).isEqualTo(A_ROOM_ID_2)
assertThat(room2.isSelected).isFalse()
assertThat(room2.isLastAdmin).isTrue()
// Deselect all
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
skipItems(1)
val stateAllDeselected = awaitItem()
val dataAllDeselected = stateAllDeselected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataAllDeselected.any { it.isSelected }).isFalse()
// Select all
stateAllDeselected.eventSink(LeaveSpaceEvents.SelectAllRooms)
skipItems(1)
val stateAllSelected = awaitItem()
val dataAllSelected = stateAllSelected.selectableSpaceRooms.dataOrNull()!!
// The last admin room should not be selected
assertThat(dataAllSelected.count { it.isSelected }).isEqualTo(1)
// Toggle selection of the first room
stateAllSelected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID))
skipItems(1)
val stateOneDeselected = awaitItem()
val dataOneDeselected = stateOneDeselected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataOneDeselected[0].isSelected).isFalse()
// Toggle selection of the first room
stateOneDeselected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID))
skipItems(1)
val stateOneSelected = awaitItem()
val dataOneSelected = stateOneSelected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataOneSelected[0].isSelected).isTrue()
// Leave space
stateOneSelected.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateLeft = awaitItem()
assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue()
leaveResult.assertions().isCalledOnce().with(
value(listOf(A_ROOM_ID))
)
}
}
@Test
fun `present - leave space error and close`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> {
Result.failure(AN_EXCEPTION)
}
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
leaveResult = leaveResult,
)
)
presenter.test {
skipItems(3)
val state = awaitItem()
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateError = awaitItem()
assertThat(stateError.leaveSpaceAction.isFailure()).isTrue()
// Close error
stateError.eventSink(LeaveSpaceEvents.CloseError)
val stateErrorClosed = awaitItem()
assertThat(stateErrorClosed.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createLeaveSpacePresenter(
leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(),
): LeaveSpacePresenter {
return LeaveSpacePresenter(
leaveSpaceHandle = leaveSpaceHandle,
)
}
}
private fun aLeaveSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(
roomId = A_SPACE_ID,
displayName = A_SPACE_NAME,
),
isLastAdmin: Boolean = false,
) = LeaveSpaceRoom(
spaceRoom = spaceRoom,
isLastAdmin = isLastAdmin,
)

View File

@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
class LeaveSpaceStateTest {
@Test
fun `test loading`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Loading()
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test no rooms`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf()
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test last admin`() {
val sut = aLeaveSpaceState(
isLastAdmin = true,
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = false),
)
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test no last admin, 1 selected, 1 not selected`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = false),
).toImmutableList()
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(1)
}
@Test
fun `test no last admin, 2 selected`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
).toImmutableList()
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
}
@Test
fun `test 1 last admin, 2 selected`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(isLastAdmin = true, isSelected = false),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
)
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
}
@Test
fun `test only last admin`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = true, isSelected = false),
aSelectableSpaceRoom(isLastAdmin = true, isSelected = false),
).toImmutableList()
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isTrue()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
}

View File

@@ -0,0 +1,341 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
class SpacePresenterTest {
@Test
fun `present - initial state`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
assertThat(state.currentSpace).isNull()
assertThat(state.children).isEmpty()
assertThat(state.seenSpaceInvites).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.hasMoreToLoad).isTrue()
assertThat(state.joinActions).isEmpty()
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden)
advanceUntilIdle()
paginateResult.assertions().isCalledOnce()
}
}
@Test
fun `present - load more`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
paginateResult.assertions().isCalledOnce()
state.eventSink(SpaceEvents.LoadMore)
advanceUntilIdle()
paginateResult.assertions().isCalledExactly(2)
}
}
@Test
fun `present - has more to load value`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.hasMoreToLoad).isTrue()
spaceRoomList.emitPaginationStatus(
SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)
)
assertThat(awaitItem().hasMoreToLoad).isFalse()
spaceRoomList.emitPaginationStatus(
SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)
)
assertThat(awaitItem().hasMoreToLoad).isTrue()
}
}
@Test
fun `present - current space value`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.currentSpace).isNull()
val aSpace = aSpaceRoom()
spaceRoomList.emitCurrentSpace(aSpace)
assertThat(awaitItem().currentSpace).isEqualTo(aSpace)
}
}
@Test
fun `present - children value`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.children).isEmpty()
val aSpace = aSpaceRoom()
spaceRoomList.emitSpaceRooms(listOf(aSpace))
assertThat(awaitItem().children).containsExactly(aSpace)
}
}
@Test
fun `present - join a room success`() = runTest {
val joinRoom = lambdaRecorder<RoomIdOrAlias, List<String>, AnalyticsJoinedRoom.Trigger, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val serverNames = listOf("via1", "via2")
val aNotJoinedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
via = serverNames,
state = null,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
aNotJoinedRoom,
),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
joinRoom = FakeJoinRoom(
lambda = joinRoom,
),
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.joinActions[A_ROOM_ID_2]).isNull()
state.eventSink(SpaceEvents.Join(aNotJoinedRoom))
val joiningState = awaitItem()
assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading)
// Let the joinRoom call complete
advanceUntilIdle()
runCurrent()
// The room is joined
fakeSpaceRoomList.emitSpaceRooms(
listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
aNotJoinedRoom.copy(state = CurrentUserMembership.JOINED),
)
)
skipItems(1)
val joinedState = awaitItem()
// Joined room is removed from the join actions
assertThat(joinedState.joinActions).doesNotContainKey(A_ROOM_ID_2)
joinRoom.assertions().isCalledOnce().with(
value(A_ROOM_ID_2.toRoomIdOrAlias()),
value(serverNames),
value(AnalyticsJoinedRoom.Trigger.SpaceHierarchy),
)
}
}
@Test
fun `present - join a room failure`() = runTest {
val aNotJoinedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
state = null,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
aNotJoinedRoom,
),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
joinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.failure(AN_EXCEPTION) },
),
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.joinActions[A_ROOM_ID_2]).isNull()
state.eventSink(SpaceEvents.Join(aNotJoinedRoom))
val joiningState = awaitItem()
assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
// Joined room is removed from the join actions
assertThat(errorState.joinActions[A_ROOM_ID_2]!!.isFailure()).isTrue()
// Clear error
errorState.eventSink(SpaceEvents.ClearFailures)
val clearedState = awaitItem()
assertThat(clearedState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - topic viewer state`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden)
advanceUntilIdle()
state.eventSink(SpaceEvents.ShowTopicViewer("topic"))
assertThat(awaitItem().topicViewerState).isEqualTo(TopicViewerState.Shown("topic"))
state.eventSink(SpaceEvents.HideTopicViewer)
assertThat(awaitItem().topicViewerState).isEqualTo(TopicViewerState.Hidden)
}
}
@Test
fun `present - accept invite is transmitted to acceptDeclineInviteState`() {
`invite action is transmitted to acceptDeclineInviteState`(
acceptInvite = true,
)
}
@Test
fun `present - decline invite is transmitted to acceptDeclineInviteState`() {
`invite action is transmitted to acceptDeclineInviteState`(
acceptInvite = false,
)
}
private fun `invite action is transmitted to acceptDeclineInviteState`(
acceptInvite: Boolean,
) = runTest {
val eventRecorder = EventsRecorder<AcceptDeclineInviteEvents>()
val anInvitedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
state = CurrentUserMembership.INVITED,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
anInvitedRoom,
),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
acceptDeclineInvitePresenter = {
anAcceptDeclineInviteState(
eventSink = eventRecorder,
)
},
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.joinActions[A_ROOM_ID_2]).isNull()
if (acceptInvite) {
state.eventSink(SpaceEvents.AcceptInvite(anInvitedRoom))
eventRecorder.assertSingle(
AcceptDeclineInviteEvents.AcceptInvite(
invite = anInvitedRoom.toInviteData(),
)
)
} else {
state.eventSink(SpaceEvents.DeclineInvite(anInvitedRoom))
eventRecorder.assertSingle(
AcceptDeclineInviteEvents.DeclineInvite(
invite = anInvitedRoom.toInviteData(),
shouldConfirm = true,
blockUser = false,
)
)
}
}
}
private fun TestScope.createSpacePresenter(
client: MatrixClient = FakeMatrixClient(),
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
joinRoom: JoinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) },
),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
): SpacePresenter {
return SpacePresenter(
client = client,
spaceRoomList = spaceRoomList,
seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
sessionCoroutineScope = backgroundScope,
)
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import org.junit.Test
class SpaceStateTest {
@Test
fun `test default state`() {
val state = aSpaceState()
assertThat(state.hasAnyFailure).isFalse()
assertThat(state.isJoining(A_ROOM_ID)).isFalse()
}
@Test
fun `test has failure`() {
val state = aSpaceState(
joinActions = mapOf(
A_ROOM_ID to AsyncAction.Uninitialized,
A_ROOM_ID_2 to AsyncAction.Failure(AN_EXCEPTION),
A_ROOM_ID_3 to AsyncAction.Success(Unit),
)
)
assertThat(state.hasAnyFailure).isTrue()
}
@Test
fun `test isJoining`() {
val state = aSpaceState(
joinActions = mapOf(
A_ROOM_ID to AsyncAction.Loading,
)
)
assertThat(state.isJoining(A_ROOM_ID)).isTrue()
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.root
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class SpaceViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<SpaceEvents>(expectEvents = false)
ensureCalledOnce {
rule.setSpaceView(
aSpaceState(
hasMoreToLoad = false,
eventSink = eventsRecorder,
),
onBackClick = it,
)
rule.pressBack()
}
}
@Test
fun `clicking on a room name invokes the expected callback`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, displayName = A_ROOM_NAME)
val eventsRecorder = EventsRecorder<SpaceEvents>(expectEvents = false)
ensureCalledOnceWithParam(aSpaceRoom) {
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
hasMoreToLoad = false,
eventSink = eventsRecorder,
),
onRoomClick = it,
)
rule.onNodeWithText(A_ROOM_NAME).performClick()
}
}
@Test
fun `clicking on Join room emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = null)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
hasMoreToLoad = false,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_join)
eventsRecorder.assertSingle(SpaceEvents.Join(aSpaceRoom))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on accept invite emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
hasMoreToLoad = false,
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(SpaceEvents.AcceptInvite(aSpaceRoom))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on decline invite emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
hasMoreToLoad = false,
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(SpaceEvents.DeclineInvite(aSpaceRoom))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on topic emits the expected Event`() {
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
parentSpace = aSpaceRoom(topic = A_ROOM_TOPIC),
hasMoreToLoad = false,
eventSink = eventsRecorder,
)
)
rule.onNodeWithText(A_ROOM_TOPIC).performClick()
eventsRecorder.assertSingle(SpaceEvents.ShowTopicViewer(A_ROOM_TOPIC))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceView(
state: SpaceState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomClick: (SpaceRoom) -> Unit = EnsureNeverCalledWithParam(),
onShareSpace: () -> Unit = EnsureNeverCalled(),
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
onDetailsClick: () -> Unit = EnsureNeverCalled(),
onViewMembersClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {},
) {
setContent {
SpaceView(
state = state,
onBackClick = onBackClick,
onRoomClick = onRoomClick,
onShareSpace = onShareSpace,
onLeaveSpaceClick = onLeaveSpaceClick,
onDetailsClick = onDetailsClick,
onViewMembersClick = onViewMembersClick,
acceptDeclineInviteView = acceptDeclineInviteView,
)
}
}