Create a new room in a space (#6061)

* Add `SpaceService.editableSpaces` and `SpaceService.addChildToSpace`

* Add `parentSpace` to `CreateRoomConfig`

* Allow setting a parent space to a room in `ConfigureRoomPresenter`, make sure the room is added to the parent space when creating it

* `ConfigureRoomPresenter`: Load the list of possible spaces a room can be added to

* Refactor `RoomVisibilityState` to internally use `JoinRuleItem`

This gets rid of `RoomAccess` and `RoomAccessItem`, and it will allow us to map the join rule items in a cleaner way to both join rules and the UI

* Implement the UI changes:

- Display the parent space.
- Allow selecting a new one.
- Import needed strings.

* Fix existing tests

* Add `@Immutable` annotation to `SpaceRoom`, since it was detected as unstable.

Maybe because of `RoomType`?

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-01-26 18:23:02 +01:00 committed by GitHub
parent 5644e9225a
commit 0313fa56dd
28 changed files with 706 additions and 172 deletions

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.previewutils)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -8,15 +8,16 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvents
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents data object CancelCreateRoom : ConfigureRoomEvents
} }

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
@ -35,7 +36,9 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
@ -45,11 +48,17 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber import timber.log.Timber
import kotlin.jvm.optionals.getOrDefault import kotlin.jvm.optionals.getOrDefault
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
class ConfigureRoomPresenter( class ConfigureRoomPresenter(
@ -78,6 +87,7 @@ class ConfigureRoomPresenter(
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState() val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState()
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
@ -105,6 +115,15 @@ class ConfigureRoomPresenter(
} }
} }
var spaces by remember { mutableStateOf<ImmutableList<SpaceRoom>>(persistentListOf()) }
LaunchedEffect(canAddRoomToSpace) {
spaces = if (canAddRoomToSpace) {
matrixClient.spaceService.editableSpaces().getOrElse { emptyList() }.toImmutableList()
} else {
persistentListOf()
}
}
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false pendingPermissionRequest = false
@ -115,7 +134,7 @@ class ConfigureRoomPresenter(
RoomAddressValidityEffect( RoomAddressValidityEffect(
client = matrixClient, client = matrixClient,
roomAliasHelper = roomAliasHelper, roomAliasHelper = roomAliasHelper,
newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""), newRoomAddress = createRoomConfig.visibilityState.roomAddress().getOrDefault(""),
knownRoomAddress = null, knownRoomAddress = null,
) { newRoomAddressValidity -> ) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity roomAddressValidity.value = newRoomAddressValidity
@ -124,12 +143,25 @@ class ConfigureRoomPresenter(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val availableVisibilityOptions = remember(isSpace, isKnockFeatureEnabled) { // Calculate available join rules based:
listOfNotNull( // 1. If we are creating a space.
RoomVisibilityItem.Public, // 2. If it has a parent space.
RoomVisibilityItem.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, // 3. If knocking is enabled.
RoomVisibilityItem.Private, val parentSpace = createRoomConfig.parentSpace
).toImmutableList() val availableJoinRules = remember(createRoomConfig.parentSpace, isSpace, isKnockFeatureEnabled) {
when {
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?")
parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.Private,
).toImmutableList()
else -> listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.Private,
).toImmutableList()
}
} }
fun createRoom(config: CreateRoomConfig) { fun createRoom(config: CreateRoomConfig) {
@ -141,8 +173,7 @@ class ConfigureRoomPresenter(
when (event) { when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem) is ConfigureRoomEvents.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress) is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig) is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvents.HandleAvatarAction -> {
@ -157,8 +188,12 @@ class ConfigureRoomPresenter(
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
} }
} }
is ConfigureRoomEvents.SetParentSpace -> {
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = AsyncAction.Uninitialized dataStore.setParentSpace(event.space)
}
ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
}
} }
} }
@ -169,7 +204,8 @@ class ConfigureRoomPresenter(
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName, homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value, roomAddressValidity = roomAddressValidity.value,
availableVisibilityOptions = availableVisibilityOptions, availableJoinRules = availableJoinRules,
spaces = spaces,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@ -180,25 +216,27 @@ class ConfigureRoomPresenter(
) = launch { ) = launch {
suspend { suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) } val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) }
val params = if (config.roomVisibility is RoomVisibilityState.Public) { val params = if (config.visibilityState is RoomVisibilityState.Public) {
CreateRoomParameters( CreateRoomParameters(
name = config.roomName, name = config.roomName,
topic = config.topic, topic = config.topic,
isEncrypted = false, isEncrypted = false,
isDirect = false, isDirect = false,
visibility = RoomVisibility.Public, visibility = RoomVisibility.Public,
joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(), joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
// No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
.takeIf { it != JoinRule.Public },
preset = RoomPreset.PUBLIC_CHAT, preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId }, invite = config.invites.map { it.userId },
avatar = avatarUrl, avatar = avatarUrl,
roomAliasName = config.roomVisibility.roomAddress(), roomAliasName = config.visibilityState.roomAddress(),
isSpace = isSpace, isSpace = isSpace,
) )
} else { } else {
CreateRoomParameters( CreateRoomParameters(
name = config.roomName, name = config.roomName,
topic = config.topic, topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private, isEncrypted = config.visibilityState is RoomVisibilityState.Private,
isDirect = false, isDirect = false,
visibility = RoomVisibility.Private, visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited, historyVisibilityOverride = RoomHistoryVisibility.Invited,
@ -208,7 +246,7 @@ class ConfigureRoomPresenter(
isSpace = isSpace, isSpace = isSpace,
) )
} }
matrixClient.createRoom(params) val roomId = matrixClient.createRoom(params)
.onFailure { failure -> .onFailure { failure ->
Timber.e(failure, "Failed to create room") Timber.e(failure, "Failed to create room")
} }
@ -217,7 +255,22 @@ class ConfigureRoomPresenter(
analyticsService.capture(CreatedRoom(isDM = false)) analyticsService.capture(CreatedRoom(isDM = false))
} }
.getOrThrow() .getOrThrow()
// Add the newly created room to the parent space too
if (config.parentSpace != null) {
Timber.d("Adding room $roomId to parent space ${config.parentSpace.roomId}")
// Wait until we receive the power level info for the room, as it's needed to check if it can be added to a space
// TODO create some SDK function that does this instead?
withTimeoutOrNull(30.seconds) {
matrixClient.getRoomInfoFlow(roomId).first { it.getOrNull()?.roomPowerLevels != null }
} ?: error("Did not receive created room power levels for room $roomId, needed for adding it to a space")
matrixClient.spaceService.addChildToSpace(spaceId = config.parentSpace.roomId, childId = roomId).getOrThrow()
}
roomId
}.runCatchingUpdatingState(createRoomAction) }.runCatchingUpdatingState(createRoomAction)
.onFailure { Timber.e(it, "Could not create room or add it to parent space ${config.parentSpace?.roomId}") }
} }
private suspend fun uploadAvatar(avatarUri: Uri): String { private suspend fun uploadAvatar(avatarUri: Uri): String {

View file

@ -10,6 +10,7 @@ package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsState
@ -22,9 +23,10 @@ data class ConfigureRoomState(
val cameraPermissionState: PermissionsState, val cameraPermissionState: PermissionsState,
val roomAddressValidity: RoomAddressValidity, val roomAddressValidity: RoomAddressValidity,
val homeserverName: String, val homeserverName: String,
val availableVisibilityOptions: ImmutableList<RoomVisibilityItem>, val availableJoinRules: ImmutableList<JoinRuleItem>,
val spaces: ImmutableList<SpaceRoom>,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { ) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true && val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.roomVisibility is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) (config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
} }

View file

@ -10,12 +10,15 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@ -28,9 +31,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(), invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
), ),
@ -39,9 +42,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(), invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
), ),
@ -49,9 +52,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
roomAddressValidity = RoomAddressValidity.NotAvailable, roomAddressValidity = RoomAddressValidity.NotAvailable,
@ -60,9 +63,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
roomAddressValidity = RoomAddressValidity.InvalidSymbols, roomAddressValidity = RoomAddressValidity.InvalidSymbols,
@ -71,9 +74,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
roomAddressValidity = RoomAddressValidity.Valid, roomAddressValidity = RoomAddressValidity.Valid,
@ -83,13 +86,41 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
isSpace = true, isSpace = true,
roomName = "Space 101", roomName = "Space 101",
topic = "Space topic for this space when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Space topic for this space when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"), roomAddress = RoomAddress.AutoFilled("Space-101"),
roomAccess = RoomAccess.Anyone, joinRuleItem = JoinRuleItem.PublicVisibility.Public,
), ),
), ),
roomAddressValidity = RoomAddressValidity.Valid, roomAddressValidity = RoomAddressValidity.Valid,
), ),
aConfigureRoomState(
config = CreateRoomConfig(
isSpace = false,
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
parentSpace = null,
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
roomAddressValidity = RoomAddressValidity.Valid,
),
aConfigureRoomState(
config = CreateRoomConfig(
isSpace = false,
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
parentSpace = aSpaceRoom(canonicalAlias = RoomAlias("#a-space-room:example.org")),
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
roomAddressValidity = RoomAddressValidity.Valid,
),
) )
} }
@ -101,9 +132,20 @@ fun aConfigureRoomState(
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org", homeserverName: String = "matrix.org",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid, roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
availableVisibilityOptions: List<RoomVisibilityItem> = RoomVisibilityItem.entries.filter { availableVisibilityOptions: List<JoinRuleItem> = if (config.parentSpace != null) {
if (!isKnockFeatureEnabled) it != RoomVisibilityItem.AskToJoin else true listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(config.parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
)
} else {
listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
)
}, },
spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { }, eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState( ) = ConfigureRoomState(
config = config, config = config,
@ -112,6 +154,7 @@ fun aConfigureRoomState(
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName, homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity, roomAddressValidity = roomAddressValidity,
availableVisibilityOptions = availableVisibilityOptions.toImmutableList(), availableJoinRules = availableVisibilityOptions.toImmutableList(),
spaces = spaces.toImmutableList(),
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
@ -46,7 +47,6 @@ 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.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -59,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.AvatarPickerState import io.element.android.libraries.matrix.ui.components.AvatarPickerState
import io.element.android.libraries.matrix.ui.components.AvatarPickerView import io.element.android.libraries.matrix.ui.components.AvatarPickerView
@ -120,27 +121,30 @@ fun ConfigureRoomView(
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
) )
RoomVisibilityAndAccessOptions( if (!state.config.isSpace && state.spaces.isNotEmpty()) {
options = state.availableVisibilityOptions, SelectParentSpaceOptions(
selected = when (state.config.roomVisibility) { spaces = state.spaces,
is RoomVisibilityState.Private -> RoomVisibilityItem.Private selectedSpace = state.config.parentSpace,
is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) { onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin )
RoomAccess.Anyone -> RoomVisibilityItem.Public }
}
}, RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
parentSpace = state.config.parentSpace,
onOptionClick = { onOptionClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it)) state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
}, },
) )
if (state.config.roomVisibility !is RoomVisibilityState.Private) { if (state.config.visibilityState !is RoomVisibilityState.Private) {
Column { Column {
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title)) ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField( RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(), address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName, homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity, addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
@ -265,7 +269,7 @@ private fun RoomTopic(
} }
@Composable @Composable
private fun ConfigureRoomOptions( internal fun ConfigureRoomOptions(
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
@ -279,10 +283,11 @@ private fun ConfigureRoomOptions(
} }
@Composable @Composable
private fun RoomVisibilityAndAccessOptions( private fun RoomJoinRuleOptions(
options: ImmutableList<RoomVisibilityItem>, options: ImmutableList<JoinRuleItem>,
selected: RoomVisibilityItem, selected: JoinRuleItem,
onOptionClick: (RoomVisibilityItem) -> Unit, onOptionClick: (JoinRuleItem) -> Unit,
parentSpace: SpaceRoom?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ConfigureRoomOptions( ConfigureRoomOptions(
@ -295,10 +300,12 @@ private fun RoomVisibilityAndAccessOptions(
leadingContent = ListItemContent.Custom { leadingContent = ListItemContent.Custom {
RoundedIconAtom( RoundedIconAtom(
size = RoundedIconAtomSize.Big, size = RoundedIconAtomSize.Big,
resourceId = when (item) { imageVector = when (item) {
RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock()
}, },
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary, tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent, backgroundTint = Color.Transparent,
@ -306,18 +313,29 @@ private fun RoomVisibilityAndAccessOptions(
}, },
headlineContent = { headlineContent = {
val title = when (item) { val title = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title) JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title) is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_title) JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_title
)
JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
} }
Text(text = title) Text(text = title)
}, },
supportingContent = { supportingContent = {
// TODO handle description of items in a certain space/org
val description = when (item) { val description = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description) JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description) is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description) R.string.screen_create_room_room_access_section_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
} }
Text(text = description) Text(text = description)
}, },

View file

@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -18,5 +19,6 @@ data class CreateRoomConfig(
val topic: String? = null, val topic: String? = null,
val avatarUri: String? = null, val avatarUri: String? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(), val invites: ImmutableList<MatrixUser> = persistentListOf(),
val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private, val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(),
val parentSpace: SpaceRoom? = null,
) )

View file

@ -12,6 +12,7 @@ import android.net.Uri
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
@ -33,23 +34,20 @@ class CreateRoomConfigStore(
fun setRoomName(roomName: String) { fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
val newVisibility = when (config.roomVisibility) { val roomAccessWithNewAddress = if (config.visibilityState is RoomVisibilityState.Public) {
is RoomVisibilityState.Public -> { val roomAddress = config.visibilityState.roomAddress
val roomAddress = config.roomVisibility.roomAddress if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) { val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName) config.visibilityState.copy(roomAddress = RoomAddress.AutoFilled(roomAliasName))
config.roomVisibility.copy( } else {
roomAddress = RoomAddress.AutoFilled(roomAliasName), config.visibilityState
)
} else {
config.roomVisibility
}
} }
else -> config.roomVisibility } else {
config.visibilityState
} }
config.copy( config.copy(
roomName = roomName.takeIf { it.isNotEmpty() }, roomName = roomName.takeIf { it.isNotEmpty() },
roomVisibility = newVisibility, visibilityState = roomAccessWithNewAddress,
) )
} }
} }
@ -67,16 +65,19 @@ class CreateRoomConfigStore(
} }
} }
fun setRoomVisibility(visibility: RoomVisibilityItem) { /**
* Sets both the room visibility and its access based on the provided join rule.
*/
fun setJoinRule(joinRule: JoinRuleItem) {
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
config.copy( config.copy(
roomVisibility = when (visibility) { visibilityState = when (joinRule) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private JoinRuleItem.Private -> RoomVisibilityState.Private()
RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> { is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty()) val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public( RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName), roomAddress = RoomAddress.AutoFilled(roomAliasName),
roomAccess = if (visibility == RoomVisibilityItem.AskToJoin) RoomAccess.Knocking else RoomAccess.Anyone, joinRuleItem = joinRule,
) )
} }
} }
@ -87,28 +88,12 @@ class CreateRoomConfigStore(
fun setRoomAddress(address: String) { fun setRoomAddress(address: String) {
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
config.copy( config.copy(
roomVisibility = when (config.roomVisibility) { visibilityState = when (config.visibilityState) {
is RoomVisibilityState.Public -> { is RoomVisibilityState.Public -> {
val sanitizedAddress = address.lowercase() val sanitizedAddress = address.lowercase()
config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress)) config.visibilityState.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
} }
else -> config.roomVisibility else -> config.visibilityState
}
)
}
}
fun setRoomAccess(access: RoomAccessItem) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
when (access) {
RoomAccessItem.Anyone -> config.roomVisibility.copy(roomAccess = RoomAccess.Anyone)
RoomAccessItem.AskToJoin -> config.roomVisibility.copy(roomAccess = RoomAccess.Knocking)
}
}
else -> config.roomVisibility
} }
) )
} }
@ -120,6 +105,15 @@ class CreateRoomConfigStore(
} }
} }
fun setParentSpace(parentSpace: SpaceRoom?) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
parentSpace = parentSpace,
visibilityState = RoomVisibilityState.Private(),
)
}
}
fun clearCachedData() { fun clearCachedData() {
cachedAvatarUri = null cachedAvatarUri = null
} }

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations 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.createroom.impl.configureroom
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.join.AllowRule
import io.element.android.libraries.matrix.api.room.join.JoinRule
import kotlinx.collections.immutable.persistentListOf
/**
* Join rule items to display in UI.
*/
@Immutable
sealed interface JoinRuleItem {
data object Private : JoinRuleItem
/**
* Those join rule items that represent public visibility of the room/space.
*/
@Immutable
sealed interface PublicVisibility : JoinRuleItem {
data object Public : PublicVisibility
data object AskToJoin : PublicVisibility
data class Restricted(val parentSpaceId: RoomId) : PublicVisibility
data class AskToJoinRestricted(val parentSpaceId: RoomId) : PublicVisibility
}
/**
* Transforms a [JoinRuleItem] option into a [JoinRule].
*/
fun toJoinRule(): JoinRule = when (this) {
Private -> JoinRule.Private
PublicVisibility.Public -> JoinRule.Public
PublicVisibility.AskToJoin -> JoinRule.Knock
is PublicVisibility.Restricted -> JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
is PublicVisibility.AskToJoinRestricted -> JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
}
}

View file

@ -1,23 +0,0 @@
/*
* 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.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.room.join.JoinRule
enum class RoomAccess {
Anyone,
Knocking
}
fun RoomAccess.toJoinRule(): JoinRule? {
return when (this) {
RoomAccess.Anyone -> null
RoomAccess.Knocking -> JoinRule.Knock
}
}

View file

@ -1,14 +0,0 @@
/*
* 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.createroom.impl.configureroom
enum class RoomAccessItem {
Anyone,
AskToJoin,
}

View file

@ -1,15 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.createroom.impl.configureroom
enum class RoomVisibilityItem {
Public,
AskToJoin,
Private
}

View file

@ -11,11 +11,12 @@ package io.element.android.features.createroom.impl.configureroom
import java.util.Optional import java.util.Optional
sealed interface RoomVisibilityState { sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState val joinRuleItem: JoinRuleItem
data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
data class Public( data class Public(
val roomAddress: RoomAddress, val roomAddress: RoomAddress,
val roomAccess: RoomAccess, override val joinRuleItem: JoinRuleItem.PublicVisibility,
) : RoomVisibilityState ) : RoomVisibilityState
fun roomAddress(): Optional<String> { fun roomAddress(): Optional<String> {

View file

@ -0,0 +1,209 @@
/*
* Copyright (c) 2026 Element Creations 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.createroom.impl.configureroom
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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 androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.createroom.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.list.ListItemContent
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.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SelectParentSpaceOptions(
spaces: ImmutableList<SpaceRoom>,
selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
var displaySelectSpaceBottomSheet by remember { mutableStateOf(false) }
ConfigureRoomOptions(
title = stringResource(CommonStrings.common_space),
modifier = modifier
) {
ListItem(
headlineContent = {
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
maxLines = 1
)
},
supportingContent = {
Text(
text = if (selectedSpace != null) {
selectedSpace.canonicalAlias?.value.orEmpty()
} else {
stringResource(R.string.screen_create_room_space_selection_no_space_description)
},
maxLines = 1
)
},
leadingContent = if (selectedSpace == null) {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Home()))
} else {
ListItemContent.Custom({
val avatarData = AvatarData(
id = selectedSpace.roomId.value,
name = selectedSpace.displayName,
url = selectedSpace.avatarUrl,
size = AvatarSize.SelectParentSpace,
)
Avatar(avatarData = avatarData, avatarType = AvatarType.Space())
})
},
onClick = { displaySelectSpaceBottomSheet = true }
)
if (displaySelectSpaceBottomSheet) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { true },
)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
sheetState.hide(coroutineScope) {
displaySelectSpaceBottomSheet = false
}
}
) {
SelectParentSpaceBottomSheet(
spaces = spaces,
selectedSpace = selectedSpace,
) {
sheetState.hide(coroutineScope) {
displaySelectSpaceBottomSheet = false
}
onSelectSpace(it)
}
}
}
}
}
@Composable
private fun ColumnScope.SelectParentSpaceBottomSheet(
spaces: ImmutableList<SpaceRoom>,
selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit,
) {
ListSectionHeader(
title = stringResource(R.string.screen_create_room_space_selection_sheet_title),
hasDivider = false
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
ListItem(
headlineContent = {
Text(
stringResource(R.string.screen_create_room_space_selection_no_space_title),
maxLines = 1
)
},
supportingContent = {
Text(
stringResource(R.string.screen_create_room_space_selection_no_space_description),
maxLines = 1
)
},
leadingContent = ListItemContent.Icon(
IconSource.Vector(CompoundIcons.Home())
),
trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == null
),
onClick = { onSelectSpace(null) },
)
}
for (space in spaces) {
item {
ListItem(
headlineContent = {
Text(
space.displayName,
maxLines = 1
)
},
supportingContent = {
Text(
space.canonicalAlias?.value.orEmpty(),
maxLines = 1
)
},
leadingContent = ListItemContent.Custom({
val avatarData =
AvatarData(
id = space.roomId.value,
name = space.displayName,
url = space.avatarUrl,
size = AvatarSize.SelectParentSpace,
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space()
)
}),
trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == space
),
onClick = { onSelectSpace(space) },
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun SelectParentSpaceBottomSheetPreview() =
ElementPreview {
Column {
SelectParentSpaceBottomSheet(
spaces = persistentListOf(
aSpaceRoom(
canonicalAlias = RoomAlias(
"#a-room-alias:example.org"
)
)
),
selectedSpace = null,
) {}
}
}

View file

@ -1,12 +1,11 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.startchat.impl.configureroom package io.element.android.features.createroom.impl
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
@ -18,24 +17,28 @@ import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPr
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore
import io.element.android.features.createroom.impl.configureroom.RoomAccess import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -47,12 +50,17 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -81,7 +89,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull() assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty() assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull() assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private) assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org") assertThat(initialState.homeserverName).isEqualTo("matrix.org")
} }
@ -174,12 +182,12 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy // Room privacy
newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) newState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy( expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")), roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
roomAccess = RoomAccess.Anyone, joinRuleItem = JoinRuleItem.PublicVisibility.Public,
) )
) )
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
@ -206,6 +214,109 @@ class ConfigureRoomPresenterTest {
} }
} }
@Test
fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
val getRoomInfoFlowLambda = lambdaRecorder<RoomId, Flow<Optional<RoomInfo>>> { roomInfoFlow }
val matrixClient = createMatrixClient(spaceService = spaceService).apply {
this.getRoomInfoFlowLambda = getRoomInfoFlowLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
// getRoomInfoFlow is called, but it contains no updates
getRoomInfoFlowLambda.assertions().isCalledOnce()
// So adding the child room to the parent space is never done
addChildToSpaceResult.assertions().isNeverCalled()
// And the operation fails
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
@Test
fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
val getRoomInfoFlowLambda = lambdaRecorder<RoomId, Flow<Optional<RoomInfo>>> { roomInfoFlow }
val matrixClient = createMatrixClient(spaceService = spaceService).apply {
this.getRoomInfoFlowLambda = getRoomInfoFlowLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
// We immediately receive the room power levels info needed for adding the child to a space
val powerLevels = RoomPowerLevels(
RoomPowerLevelsValues(
ban = 0,
invite = 0,
kick = 0,
eventsDefault = 0,
stateDefault = 0,
redactEvents = 0,
roomName = 0,
roomAvatar = 0,
roomTopic = 0,
spaceChild = 0
),
users = persistentMapOf(),
)
roomInfoFlow.value = Optional.of(aRoomInfo(roomPowerLevels = powerLevels))
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
// The room info flow was read
getRoomInfoFlowLambda.assertions().isCalledOnce()
// Since it contained the power levels, the operation continued
addChildToSpaceResult.assertions().isCalledOnce()
// And the child room was created and then added to the parent space
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull())
}
}
@Test @Test
fun `present - record analytics when creating room`() = runTest { fun `present - record analytics when creating room`() = runTest {
val matrixClient = createMatrixClient() val matrixClient = createMatrixClient()
@ -311,7 +422,7 @@ class ConfigureRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address")) initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
skipItems(1) skipItems(1)
@ -331,7 +442,7 @@ class ConfigureRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1) skipItems(1)
@ -351,7 +462,7 @@ class ConfigureRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1) skipItems(1)
@ -362,12 +473,51 @@ class ConfigureRoomPresenterTest {
} }
} }
@Test
fun `present - when a space is selected, the selected join rule is reset to private`() = runTest {
val presenter = createConfigureRoomPresenter()
presenter.test {
val initialState = initialState()
// First change the join rule to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
// If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then remove the parent space, it'll be private again
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
}
}
@Test
fun `present - setting a parent space for a space currently throws an error`() = runTest {
val presenter = createConfigureRoomPresenter(isSpace = true)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
assertThat(awaitError())
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState { private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
} }
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient( private fun createMatrixClient(
isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" }, userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = { resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) { val resolvedRoomAlias = if (isAliasAvailable) {
@ -376,7 +526,8 @@ class ConfigureRoomPresenterTest {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
} }
Result.success(resolvedRoomAlias) Result.success(resolvedRoomAlias)
} },
spaceService = spaceService,
) )
private fun createConfigureRoomPresenter( private fun createConfigureRoomPresenter(

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations 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.createroom.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.libraries.matrix.api.room.join.AllowRule
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.A_ROOM_ID
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class JoinRuleItemTest {
@Test
fun `toJoinRule works as expected`() {
assertThat(JoinRuleItem.Private.toJoinRule()).isEqualTo(JoinRule.Private)
assertThat(JoinRuleItem.PublicVisibility.Public.toJoinRule()).isEqualTo(JoinRule.Public)
assertThat(JoinRuleItem.PublicVisibility.AskToJoin.toJoinRule()).isEqualTo(JoinRule.Knock)
assertThat(JoinRuleItem.PublicVisibility.Restricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
assertThat(JoinRuleItem.PublicVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
}
}

View file

@ -74,6 +74,7 @@ enum class AvatarSize(val dp: Dp) {
RoomPreviewInviter(56.dp), RoomPreviewInviter(56.dp),
SpaceMember(24.dp), SpaceMember(24.dp),
LeaveSpaceRoom(32.dp), LeaveSpaceRoom(32.dp),
SelectParentSpace(32.dp),
AccountItem(32.dp), AccountItem(32.dp),
} }

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.spaces package io.element.android.libraries.matrix.api.spaces
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@ -16,6 +17,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable
data class SpaceRoom( data class SpaceRoom(
val rawName: String?, val rawName: String?,
val displayName: String, val displayName: String,

View file

@ -21,6 +21,11 @@ interface SpaceService {
fun spaceRoomList(id: RoomId): SpaceRoomList fun spaceRoomList(id: RoomId): SpaceRoomList
/**
* Get the list of spaces in which the current user can modify their rooms (adding or removing them).
*/
suspend fun editableSpaces(): Result<List<SpaceRoom>>
fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle
/** /**

View file

@ -87,6 +87,12 @@ class RustSpaceService(
) )
} }
override suspend fun editableSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.editableSpaces().map(spaceRoomMapper::map)
}
}
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return RustLeaveSpaceHandle( return RustLeaveSpaceHandle(
id = spaceId, id = spaceId,

View file

@ -23,10 +23,11 @@ class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() }, private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
private val addChildToSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() }, private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
private val joinedParentsResult: (RoomId) -> Result<List<SpaceRoom>> = { lambdaError() }, private val joinedParentsResult: (RoomId) -> Result<List<SpaceRoom>> = { lambdaError() },
private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() }, private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() },
private val editableSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val addChildToSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
) : SpaceService { ) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>() private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>> override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@ -52,6 +53,10 @@ class FakeSpaceService(
return spaceRoomListResult(id) return spaceRoomListResult(id)
} }
override suspend fun editableSpaces(): Result<List<SpaceRoom>> {
return editableSpacesResult()
}
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return leaveSpaceHandleResult(spaceId) return leaveSpaceHandleResult(spaceId)
} }

View file

@ -28,6 +28,7 @@ class KonsistComposableTest {
.functions() .functions()
.withTopLevel() .withTopLevel()
.withoutModifier(KoModifier.PRIVATE) .withoutModifier(KoModifier.PRIVATE)
.withoutModifier(KoModifier.INTERNAL)
.withoutNameEndingWith("Preview") .withoutNameEndingWith("Preview")
.withAllAnnotationsOf(Composable::class) .withAllAnnotationsOf(Composable::class)
.withoutReceiverType() .withoutReceiverType()

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c949830fae61474e63adb56f9d3b355db203a4c2902fcef4605ac6b6a5c5148a
size 45333

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4bf55777af9384a34c438de10887f5b6dff107b837f4bb7a2f974644c256047
size 48431

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:286ec7cfa373ebf4bcd8704895a0398f4b4fe7710c2608b325a842dae3e7a294
size 46955

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e95d2194cfc8b979624ce3b97250a85051d67bd8f5f61bb1e60d274c08f4649
size 50132

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7168b3f34dbd9bdbb34682aecbc1f81e16ae7be6af7e832f6e18e0d090f38f2
size 21093

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d03b34a155b919fd17b280448991299294a0feda120c1f1e4a4ce2d967073294
size 19942