From 0313fa56dde0ac4637c84316497b278020687185 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 26 Jan 2026 18:23:02 +0100 Subject: [PATCH] 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 --- features/createroom/impl/build.gradle.kts | 1 + .../impl/configureroom/ConfigureRoomEvents.kt | 5 +- .../configureroom/ConfigureRoomPresenter.kt | 87 ++++++-- .../impl/configureroom/ConfigureRoomState.kt | 6 +- .../ConfigureRoomStateProvider.kt | 73 ++++-- .../impl/configureroom/ConfigureRoomView.kt | 76 ++++--- .../impl/configureroom/CreateRoomConfig.kt | 4 +- .../configureroom/CreateRoomConfigStore.kt | 68 +++--- .../impl/configureroom/JoinRuleItem.kt | 44 ++++ .../impl/configureroom/RoomAccess.kt | 23 -- .../impl/configureroom/RoomAccessItem.kt | 14 -- .../impl/configureroom/RoomVisibilityItem.kt | 15 -- .../impl/configureroom/RoomVisibilityState.kt | 5 +- .../configureroom/SelectParentSpaceOptions.kt | 209 ++++++++++++++++++ .../impl}/ConfigureRoomPresenterTest.kt | 179 +++++++++++++-- .../createroom/impl/JoinRuleItemTest.kt | 29 +++ .../components/avatar/AvatarSize.kt | 1 + .../libraries/matrix/api/spaces/SpaceRoom.kt | 2 + .../matrix/api/spaces/SpaceService.kt | 5 + .../matrix/impl/spaces/RustSpaceService.kt | 6 + .../matrix/test/spaces/FakeSpaceService.kt | 7 +- .../tests/konsist/KonsistComposableTest.kt | 1 + ...nfigureroom_ConfigureRoomViewDark_7_en.png | 3 + ...nfigureroom_ConfigureRoomViewDark_8_en.png | 3 + ...figureroom_ConfigureRoomViewLight_7_en.png | 3 + ...figureroom_ConfigureRoomViewLight_8_en.png | 3 + ..._SelectParentSpaceBottomSheet_Day_0_en.png | 3 + ...electParentSpaceBottomSheet_Night_0_en.png | 3 + 28 files changed, 706 insertions(+), 172 deletions(-) create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt delete mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt delete mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt delete mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt rename features/createroom/impl/src/test/kotlin/io/element/android/features/{startchat/impl/configureroom => createroom/impl}/ConfigureRoomPresenterTest.kt (68%) create mode 100644 features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Night_0_en.png diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 1adaac1f6d..7201f7dc9c 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -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) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt index fed8404143..5de99e519d 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt @@ -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 } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index d14fa82656..8f231a440f 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -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>(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> = 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 { diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt index d50d057061..9e88a7af79 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt @@ -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, + val availableJoinRules: ImmutableList, + val spaces: ImmutableList, 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) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt index 577daf6b3e..68a1f4b43b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -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 { @@ -28,9 +31,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider = RoomVisibilityItem.entries.filter { - if (!isKnockFeatureEnabled) it != RoomVisibilityItem.AskToJoin else true + availableVisibilityOptions: List = 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 = 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, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index 4ae5626ee3..39849174dd 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -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, - selected: RoomVisibilityItem, - onOptionClick: (RoomVisibilityItem) -> Unit, +private fun RoomJoinRuleOptions( + options: ImmutableList, + 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) }, diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt index 8355047ddf..dc24db1516 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt @@ -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 = persistentListOf(), - val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private, + val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(), + val parentSpace: SpaceRoom? = null, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt index 81c9638f0c..d1d4f22322 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt @@ -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 } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt new file mode 100644 index 0000000000..428cf648ec --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt @@ -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))) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt deleted file mode 100644 index 9d8167cce2..0000000000 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt +++ /dev/null @@ -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 - } -} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt deleted file mode 100644 index 9d140b952c..0000000000 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt +++ /dev/null @@ -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, -} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt deleted file mode 100644 index feff1ee90f..0000000000 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt +++ /dev/null @@ -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 -} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt index 82af5a5614..7fd8bd888c 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt @@ -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 { diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt new file mode 100644 index 0000000000..26c36f3e60 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt @@ -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, + 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, + 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, + ) {} + } + } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt similarity index 68% rename from features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt rename to features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index e82d8c3912..a4d653fb63 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -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> { _, _ -> Result.success(Unit) } + val spaceService = FakeSpaceService( + addChildToSpaceResult = addChildToSpaceResult, + ) + val roomInfoFlow = MutableStateFlow>(Optional.empty()) + val getRoomInfoFlowLambda = lambdaRecorder>> { 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> { _, _ -> Result.success(Unit) } + val spaceService = FakeSpaceService( + addChildToSpaceResult = addChildToSpaceResult, + ) + val roomInfoFlow = MutableStateFlow>(Optional.empty()) + val getRoomInfoFlowLambda = lambdaRecorder>> { 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.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( diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt new file mode 100644 index 0000000000..1d6083e050 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt @@ -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)))) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 4124d7a967..8407445394 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -74,6 +74,7 @@ enum class AvatarSize(val dp: Dp) { RoomPreviewInviter(56.dp), SpaceMember(24.dp), LeaveSpaceRoom(32.dp), + SelectParentSpace(32.dp), AccountItem(32.dp), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt index d21c3764f3..6a72577760 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt @@ -8,6 +8,7 @@ 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.RoomId 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 kotlinx.collections.immutable.ImmutableList +@Immutable data class SpaceRoom( val rawName: String?, val displayName: String, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 39ea699ae3..1122415d58 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -21,6 +21,11 @@ interface SpaceService { 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> + fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 771da5a3d5..4d9bc2fe61 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -87,6 +87,12 @@ class RustSpaceService( ) } + override suspend fun editableSpaces(): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService.editableSpaces().map(spaceRoomMapper::map) + } + } + override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { return RustLeaveSpaceHandle( id = spaceId, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt index 2db83b601a..b7a40fdeef 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -23,10 +23,11 @@ class FakeSpaceService( private val joinedSpacesResult: () -> Result> = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, - private val addChildToSpaceResult: (RoomId, RoomId) -> Result = { _, _ -> lambdaError() }, private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result = { _, _ -> lambdaError() }, private val joinedParentsResult: (RoomId) -> Result> = { lambdaError() }, private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() }, + private val editableSpacesResult: () -> Result> = { lambdaError() }, + private val addChildToSpaceResult: (RoomId, RoomId) -> Result = { _, _ -> lambdaError() }, ) : SpaceService { private val _spaceRoomsFlow = MutableSharedFlow>() override val spaceRoomsFlow: SharedFlow> @@ -52,6 +53,10 @@ class FakeSpaceService( return spaceRoomListResult(id) } + override suspend fun editableSpaces(): Result> { + return editableSpacesResult() + } + override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { return leaveSpaceHandleResult(spaceId) } diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt index b3db2afd38..dcd689c14a 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt @@ -28,6 +28,7 @@ class KonsistComposableTest { .functions() .withTopLevel() .withoutModifier(KoModifier.PRIVATE) + .withoutModifier(KoModifier.INTERNAL) .withoutNameEndingWith("Preview") .withAllAnnotationsOf(Composable::class) .withoutReceiverType() diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_7_en.png new file mode 100644 index 0000000000..9d1c0890ae --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c949830fae61474e63adb56f9d3b355db203a4c2902fcef4605ac6b6a5c5148a +size 45333 diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_8_en.png new file mode 100644 index 0000000000..9ea4f88b93 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewDark_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4bf55777af9384a34c438de10887f5b6dff107b837f4bb7a2f974644c256047 +size 48431 diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_7_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_7_en.png new file mode 100644 index 0000000000..cb9571dd0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:286ec7cfa373ebf4bcd8704895a0398f4b4fe7710c2608b325a842dae3e7a294 +size 46955 diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_8_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_8_en.png new file mode 100644 index 0000000000..80a7226c30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_ConfigureRoomViewLight_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e95d2194cfc8b979624ce3b97250a85051d67bd8f5f61bb1e60d274c08f4649 +size 50132 diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Day_0_en.png new file mode 100644 index 0000000000..0f51143564 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7168b3f34dbd9bdbb34682aecbc1f81e16ae7be6af7e832f6e18e0d090f38f2 +size 21093 diff --git a/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Night_0_en.png new file mode 100644 index 0000000000..96eeea0d38 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.createroom.impl.configureroom_SelectParentSpaceBottomSheet_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d03b34a155b919fd17b280448991299294a0feda120c1f1e4a4ce2d967073294 +size 19942