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.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.previewutils)
implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)

View File

@@ -8,15 +8,16 @@
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
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data class SetParentSpace(val space: SpaceRoom?) : 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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import dev.zacsweers.metro.Assisted
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.room.alias.RoomAliasHelper
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.spaces.SpaceRoom
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.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.PermissionsPresenter
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.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.jvm.optionals.getOrDefault
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.seconds
@AssistedInject
class ConfigureRoomPresenter(
@@ -78,6 +87,7 @@ class ConfigureRoomPresenter(
@Composable
override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState()
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) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
@@ -115,7 +134,7 @@ class ConfigureRoomPresenter(
RoomAddressValidityEffect(
client = matrixClient,
roomAliasHelper = roomAliasHelper,
newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""),
newRoomAddress = createRoomConfig.visibilityState.roomAddress().getOrDefault(""),
knownRoomAddress = null,
) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity
@@ -124,12 +143,25 @@ class ConfigureRoomPresenter(
val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val availableVisibilityOptions = remember(isSpace, isKnockFeatureEnabled) {
listOfNotNull(
RoomVisibilityItem.Public,
RoomVisibilityItem.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
RoomVisibilityItem.Private,
).toImmutableList()
// Calculate available join rules based:
// 1. If we are creating a space.
// 2. If it has a parent space.
// 3. If knocking is enabled.
val parentSpace = createRoomConfig.parentSpace
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) {
@@ -141,8 +173,7 @@ class ConfigureRoomPresenter(
when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> {
@@ -157,8 +188,12 @@ class ConfigureRoomPresenter(
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = AsyncAction.Uninitialized
is ConfigureRoomEvents.SetParentSpace -> {
dataStore.setParentSpace(event.space)
}
ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
}
}
}
@@ -169,7 +204,8 @@ class ConfigureRoomPresenter(
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value,
availableVisibilityOptions = availableVisibilityOptions,
availableJoinRules = availableJoinRules,
spaces = spaces,
eventSink = ::handleEvent,
)
}
@@ -180,25 +216,27 @@ class ConfigureRoomPresenter(
) = launch {
suspend {
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(
name = config.roomName,
topic = config.topic,
isEncrypted = false,
isDirect = false,
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,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
roomAliasName = config.roomVisibility.roomAddress(),
roomAliasName = config.visibilityState.roomAddress(),
isSpace = isSpace,
)
} else {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isEncrypted = config.visibilityState is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
@@ -208,7 +246,7 @@ class ConfigureRoomPresenter(
isSpace = isSpace,
)
}
matrixClient.createRoom(params)
val roomId = matrixClient.createRoom(params)
.onFailure { failure ->
Timber.e(failure, "Failed to create room")
}
@@ -217,7 +255,22 @@ class ConfigureRoomPresenter(
analyticsService.capture(CreatedRoom(isDM = false))
}
.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)
.onFailure { Timber.e(it, "Could not create room or add it to parent space ${config.parentSpace?.roomId}") }
}
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.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.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
@@ -22,9 +23,10 @@ data class ConfigureRoomState(
val cameraPermissionState: PermissionsState,
val roomAddressValidity: RoomAddressValidity,
val homeserverName: String,
val availableVisibilityOptions: ImmutableList<RoomVisibilityItem>,
val availableJoinRules: ImmutableList<JoinRuleItem>,
val spaces: ImmutableList<SpaceRoom>,
val eventSink: (ConfigureRoomEvents) -> Unit
) {
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 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.spaces.SpaceRoom
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.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@@ -28,9 +31,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
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",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
),
@@ -39,9 +42,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
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",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
),
@@ -49,9 +52,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
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",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
roomAddressValidity = RoomAddressValidity.NotAvailable,
@@ -60,9 +63,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
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",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
roomAddressValidity = RoomAddressValidity.InvalidSymbols,
@@ -71,9 +74,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
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",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
roomAddressValidity = RoomAddressValidity.Valid,
@@ -83,13 +86,41 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
isSpace = true,
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",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
roomAccess = RoomAccess.Anyone,
joinRuleItem = JoinRuleItem.PublicVisibility.Public,
),
),
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),
homeserverName: String = "matrix.org",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
availableVisibilityOptions: List<RoomVisibilityItem> = RoomVisibilityItem.entries.filter {
if (!isKnockFeatureEnabled) it != RoomVisibilityItem.AskToJoin else true
availableVisibilityOptions: List<JoinRuleItem> = if (config.parentSpace != null) {
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 = { },
) = ConfigureRoomState(
config = config,
@@ -112,6 +154,7 @@ fun aConfigureRoomState(
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity,
availableVisibilityOptions = availableVisibilityOptions.toImmutableList(),
availableJoinRules = availableVisibilityOptions.toImmutableList(),
spaces = spaces.toImmutableList(),
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.unit.dp
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.libraries.architecture.coverage.ExcludeFromCoverage
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.button.BackButton
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.preview.ElementPreviewDark
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.TopAppBar
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.AvatarPickerState
import io.element.android.libraries.matrix.ui.components.AvatarPickerView
@@ -120,27 +121,30 @@ fun ConfigureRoomView(
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
RoomVisibilityAndAccessOptions(
options = state.availableVisibilityOptions,
selected = when (state.config.roomVisibility) {
is RoomVisibilityState.Private -> RoomVisibilityItem.Private
is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) {
RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin
RoomAccess.Anyone -> RoomVisibilityItem.Public
}
},
if (!state.config.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions(
spaces = state.spaces,
selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
)
}
RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
parentSpace = state.config.parentSpace,
onOptionClick = {
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 {
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
@@ -265,7 +269,7 @@ private fun RoomTopic(
}
@Composable
private fun ConfigureRoomOptions(
internal fun ConfigureRoomOptions(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
@@ -279,10 +283,11 @@ private fun ConfigureRoomOptions(
}
@Composable
private fun RoomVisibilityAndAccessOptions(
options: ImmutableList<RoomVisibilityItem>,
selected: RoomVisibilityItem,
onOptionClick: (RoomVisibilityItem) -> Unit,
private fun RoomJoinRuleOptions(
options: ImmutableList<JoinRuleItem>,
selected: JoinRuleItem,
onOptionClick: (JoinRuleItem) -> Unit,
parentSpace: SpaceRoom?,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
@@ -295,10 +300,12 @@ private fun RoomVisibilityAndAccessOptions(
leadingContent = ListItemContent.Custom {
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
resourceId = when (item) {
RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public
RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add
RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock
imageVector = when (item) {
JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock()
},
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent,
@@ -306,18 +313,29 @@ private fun RoomVisibilityAndAccessOptions(
},
headlineContent = {
val title = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_title)
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_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)
},
supportingContent = {
// TODO handle description of items in a certain space/org
val description = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description)
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
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)
},

View File

@@ -8,6 +8,7 @@
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -18,5 +19,6 @@ data class CreateRoomConfig(
val topic: String? = null,
val avatarUri: String? = null,
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 io.element.android.libraries.androidutils.file.safeDelete
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.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
@@ -33,23 +34,20 @@ class CreateRoomConfigStore(
fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config ->
val newVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val roomAddress = config.roomVisibility.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
config.roomVisibility.copy(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
)
} else {
config.roomVisibility
}
val roomAccessWithNewAddress = if (config.visibilityState is RoomVisibilityState.Public) {
val roomAddress = config.visibilityState.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
config.visibilityState.copy(roomAddress = RoomAddress.AutoFilled(roomAliasName))
} else {
config.visibilityState
}
else -> config.roomVisibility
} else {
config.visibilityState
}
config.copy(
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 ->
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> {
visibilityState = when (joinRule) {
JoinRuleItem.Private -> RoomVisibilityState.Private()
is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
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) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
visibilityState = when (config.visibilityState) {
is RoomVisibilityState.Public -> {
val sanitizedAddress = address.lowercase()
config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
config.visibilityState.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
}
else -> config.roomVisibility
}
)
}
}
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
else -> config.visibilityState
}
)
}
@@ -120,6 +105,15 @@ class CreateRoomConfigStore(
}
}
fun setParentSpace(parentSpace: SpaceRoom?) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
parentSpace = parentSpace,
visibilityState = RoomVisibilityState.Private(),
)
}
}
fun clearCachedData() {
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
sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState
val joinRuleItem: JoinRuleItem
data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,
val roomAccess: RoomAccess,
override val joinRuleItem: JoinRuleItem.PublicVisibility,
) : RoomVisibilityState
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 2023-2025 New Vector Ltd.
* 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.startchat.impl.configureroom
package io.element.android.features.createroom.impl
import android.net.Uri
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.CreateRoomConfig
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.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
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.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_EXCEPTION
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_NAME
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.spaces.FakeSpaceService
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
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.test.FakePermissionsPresenter
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.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -81,7 +89,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
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.homeserverName).isEqualTo("matrix.org")
}
@@ -174,12 +182,12 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy
newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
newState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState = awaitItem()
expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
roomAccess = RoomAccess.Anyone,
joinRuleItem = JoinRuleItem.PublicVisibility.Public,
)
)
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
fun `present - record analytics when creating room`() = runTest {
val matrixClient = createMatrixClient()
@@ -311,7 +422,7 @@ class ConfigureRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
skipItems(1)
@@ -331,7 +442,7 @@ class ConfigureRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
@@ -351,7 +462,7 @@ class ConfigureRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
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 {
skipItems(1)
return awaitItem()
}
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
private fun createMatrixClient(
isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) {
@@ -376,7 +526,8 @@ class ConfigureRoomPresenterTest {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
}
Result.success(resolvedRoomAlias)
}
},
spaceService = spaceService,
)
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))))
}
}