diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt index 4c91da0301..95d97156ff 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -25,6 +25,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode import io.element.android.features.space.impl.di.SpaceFlowGraph import io.element.android.features.space.impl.leave.LeaveSpaceNode import io.element.android.features.space.impl.root.SpaceNode @@ -69,6 +70,9 @@ class SpaceFlowNode( @Parcelize data object Leave : NavTarget + + @Parcelize + data object AddRoom : NavTarget } override fun onBuilt() { @@ -111,6 +115,10 @@ class SpaceFlowNode( override fun startLeaveSpaceFlow() { backstack.push(NavTarget.Leave) } + + override fun navigateToAddRoom() { + backstack.push(NavTarget.AddRoom) + } } createNode(buildContext, listOf(callback)) } @@ -132,6 +140,14 @@ class SpaceFlowNode( } createNode(buildContext, listOf(callback)) } + NavTarget.AddRoom -> { + val callback = object : AddRoomToSpaceNode.Callback { + override fun onFinish() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt new file mode 100644 index 0000000000..eb3f2d0829 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt @@ -0,0 +1,19 @@ +/* + * 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.space.impl.addroom + +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo + +sealed interface AddRoomToSpaceEvent { + data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvent + data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvent + data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent + data object Save : AddRoomToSpaceEvent + data object CloseSearch : AddRoomToSpaceEvent + data object ResetSaveAction : AddRoomToSpaceEvent +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt new file mode 100644 index 0000000000..71e7665844 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt @@ -0,0 +1,48 @@ +/* + * 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.space.impl.addroom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.callback + +@ContributesNode(SpaceFlowScope::class) +@AssistedInject +class AddRoomToSpaceNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AddRoomToSpacePresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onFinish() + } + + private val callback: Callback = callback() + private val stateFlow = launchMolecule { presenter.present() } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + AddRoomToSpaceView( + state = state, + onBackClick = ::navigateUp, + onRoomsAdded = callback::onFinish, + modifier = modifier + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt new file mode 100644 index 0000000000..25fa421d1f --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt @@ -0,0 +1,140 @@ +/* + * 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.space.impl.addroom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch + +@Inject +class AddRoomToSpacePresenter( + private val spaceRoomList: SpaceRoomList, + private val spaceService: SpaceService, + private val dataSourceFactory: AddRoomToSpaceSearchDataSource.Factory, +) : Presenter { + @Composable + override fun present(): AddRoomToSpaceState { + var selectedRooms: ImmutableList by remember { mutableStateOf(persistentListOf()) } + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + val coroutineScope = rememberCoroutineScope() + val dataSource = remember { dataSourceFactory.create(coroutineScope) } + + // Update search query in data source + LaunchedEffect(searchQuery) { + dataSource.setSearchQuery(searchQuery) + } + LaunchedEffect(isSearchActive) { + dataSource.setIsActive(isSearchActive) + } + + val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf()) + + val filteredRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) + val searchResults by remember>>> { + derivedStateOf { + when { + filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms) + isSearchActive && searchQuery.isNotEmpty() -> SearchBarResultState.NoResultsFound() + else -> SearchBarResultState.Initial() + } + } + } + + fun handleEvent(event: AddRoomToSpaceEvent) { + when (event) { + is AddRoomToSpaceEvent.ToggleRoom -> { + selectedRooms = if (selectedRooms.any { it.roomId == event.room.roomId }) { + selectedRooms.filterNot { it.roomId == event.room.roomId }.toImmutableList() + } else { + (selectedRooms + event.room).toImmutableList() + } + } + is AddRoomToSpaceEvent.UpdateSearchQuery -> { + searchQuery = event.query + } + is AddRoomToSpaceEvent.OnSearchActiveChanged -> { + isSearchActive = event.active + if (!event.active) { + searchQuery = "" + } + } + AddRoomToSpaceEvent.CloseSearch -> { + isSearchActive = false + searchQuery = "" + } + AddRoomToSpaceEvent.Save -> { + coroutineScope.addRoomsToSpace( + selectedRooms = selectedRooms, + addAction = saveAction, + ) + } + AddRoomToSpaceEvent.ResetSaveAction -> { + saveAction.value = AsyncAction.Uninitialized + } + } + } + + return AddRoomToSpaceState( + searchQuery = searchQuery, + isSearchActive = isSearchActive, + searchResults = searchResults, + selectedRooms = selectedRooms, + suggestions = suggestions, + saveAction = saveAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.addRoomsToSpace( + selectedRooms: ImmutableList, + addAction: MutableState>, + ) = launch { + addAction.runUpdatingState { + val results = selectedRooms.map { selectedRoom -> + async { + spaceService.addChildToSpace( + spaceId = spaceRoomList.roomId, + childId = selectedRoom.roomId, + ) + } + }.awaitAll() + val anyFailure = results.any { it.isFailure } + if (anyFailure) { + Result.failure(Exception("Failed to add some rooms")) + } else { + Result.success(Unit) + } + } + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt new file mode 100644 index 0000000000..61f08d53ae --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt @@ -0,0 +1,111 @@ +/* + * 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.space.impl.addroom + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.recent.getRecentlyVisitedRoomInfoFlow +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList + +private const val PAGE_SIZE = 30 +private const val MAX_SUGGESTIONS_COUNT = 5 + +/** + * DataSource for rooms that can be added to a space. + * Filters out DMs, spaces, rooms already in the space, and only includes rooms the user has joined. + */ +@AssistedInject +class AddRoomToSpaceSearchDataSource( + @Assisted coroutineScope: CoroutineScope, + roomListService: RoomListService, + spaceRoomList: SpaceRoomList, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, +) { + @AssistedFactory + interface Factory { + fun create(coroutineScope: CoroutineScope): AddRoomToSpaceSearchDataSource + } + + private val roomList = roomListService.createRoomList( + pageSize = PAGE_SIZE, + initialFilter = RoomListFilter.all(), + source = RoomList.Source.All, + coroutineScope = coroutineScope, + ) + + private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { spaceChildren -> + spaceChildren.map { it.roomId }.toSet() + } + + private val filterRoomPredicate: (RoomInfo, Set) -> Boolean = { info, childIds -> + !info.isSpace && + !info.isDm && + info.currentUserMembership == CurrentUserMembership.JOINED && + info.id !in childIds + } + + val roomInfoList: Flow> = combine( + roomList.filteredSummaries, + spaceChildrenFlow, + ) { roomSummaries, childIds -> + roomSummaries + .filter { filterRoomPredicate(it.info, childIds) } + .map { it.toSelectRoomInfo() } + .toImmutableList() + }.flowOn(coroutineDispatchers.computation) + + val suggestions: Flow> = spaceChildrenFlow.map { childIds -> + matrixClient + .getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds) } + .take(MAX_SUGGESTIONS_COUNT) + .map { it.toSelectRoomInfo() } + .toList() + .toImmutableList() + }.flowOn(coroutineDispatchers.computation) + + suspend fun setIsActive(isActive: Boolean) = coroutineScope { + if (isActive) { + roomList.loadAllIncrementally(this) + } else { + roomList.reset() + } + } + + suspend fun setSearchQuery(searchQuery: String) { + val filter = if (searchQuery.isBlank()) { + RoomListFilter.None + } else { + RoomListFilter.NormalizedMatchRoomName(searchQuery) + } + roomList.updateFilter(filter) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceState.kt new file mode 100644 index 0000000000..8d7ff0ce6b --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceState.kt @@ -0,0 +1,25 @@ +/* + * 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.space.impl.addroom + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import kotlinx.collections.immutable.ImmutableList + +data class AddRoomToSpaceState( + val searchQuery: String, + val isSearchActive: Boolean, + val searchResults: SearchBarResultState>, + val selectedRooms: ImmutableList, + val suggestions: ImmutableList, + val saveAction: AsyncAction, + val eventSink: (AddRoomToSpaceEvent) -> Unit, +) { + val canSave: Boolean = selectedRooms.isNotEmpty() && !saveAction.isLoading() +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt new file mode 100644 index 0000000000..640e7ede7d --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt @@ -0,0 +1,107 @@ +/* + * 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.space.impl.addroom + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class AddRoomToSpaceStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Initial state with suggestions + anAddRoomToSpaceState( + suggestions = aSelectRoomInfoList(), + ), + // Search active, empty query + anAddRoomToSpaceState( + isSearchActive = true, + searchQuery = "", + suggestions = aSelectRoomInfoList(), + ), + // Search active with query and results + anAddRoomToSpaceState( + isSearchActive = true, + searchQuery = "general", + searchResults = SearchBarResultState.Results(aSelectRoomInfoList()), + ), + // Search active with query and no results + anAddRoomToSpaceState( + isSearchActive = true, + searchQuery = "unknown", + searchResults = SearchBarResultState.NoResultsFound(), + ), + // With selected rooms + anAddRoomToSpaceState( + suggestions = aSelectRoomInfoList(), + selectedRooms = aSelectRoomInfoList().take(1).toImmutableList(), + ), + // Loading state + anAddRoomToSpaceState( + selectedRooms = aSelectRoomInfoList().take(1).toImmutableList(), + saveAction = AsyncAction.Loading, + ), + // Error state + anAddRoomToSpaceState( + selectedRooms = aSelectRoomInfoList().take(1).toImmutableList(), + saveAction = AsyncAction.Failure(Exception("Failed to add rooms")), + ), + ) +} + +internal fun anAddRoomToSpaceState( + searchQuery: String = "", + searchResults: SearchBarResultState> = SearchBarResultState.Initial(), + selectedRooms: ImmutableList = persistentListOf(), + isSearchActive: Boolean = false, + saveAction: AsyncAction = AsyncAction.Uninitialized, + suggestions: ImmutableList = persistentListOf(), + eventSink: (AddRoomToSpaceEvent) -> Unit = {}, +): AddRoomToSpaceState { + return AddRoomToSpaceState( + searchQuery = searchQuery, + searchResults = searchResults, + selectedRooms = selectedRooms, + isSearchActive = isSearchActive, + saveAction = saveAction, + suggestions = suggestions, + eventSink = eventSink, + ) +} + +internal fun aSelectRoomInfoList(): ImmutableList = listOf( + SelectRoomInfo( + roomId = RoomId("!room1:server.org"), + name = "General", + canonicalAlias = null, + avatarUrl = null, + heroes = persistentListOf(), + isTombstoned = false, + ), + SelectRoomInfo( + roomId = RoomId("!room2:server.org"), + name = "Engineering", + canonicalAlias = null, + avatarUrl = null, + heroes = persistentListOf(), + isTombstoned = false, + ), + SelectRoomInfo( + roomId = RoomId("!room3:server.org"), + name = "Design", + canonicalAlias = null, + avatarUrl = null, + heroes = persistentListOf(), + isTombstoned = false, + ), +).toImmutableList() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt new file mode 100644 index 0000000000..c3696dcac0 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt @@ -0,0 +1,246 @@ +/* + * 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.space.impl.addroom + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.space.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +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.AvatarListItem +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.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.SelectedRoom +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddRoomToSpaceView( + state: AddRoomToSpaceState, + onBackClick: () -> Unit, + onRoomsAdded: () -> Unit, + modifier: Modifier = Modifier, +) { + fun onRoomRemoved(roomInfo: SelectRoomInfo) { + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(roomInfo)) + } + + fun onBack() { + if (state.isSearchActive) { + state.eventSink(AddRoomToSpaceEvent.CloseSearch) + } else { + onBackClick() + } + } + + BackHandler(onBack = ::onBack) + + // Navigate back on success + LaunchedEffect(state.saveAction) { + if (state.saveAction is AsyncAction.Success) { + onRoomsAdded() + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.action_add_existing_rooms), + navigationIcon = { + BackButton(onClick = ::onBack) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = state.canSave, + onClick = { state.eventSink(AddRoomToSpaceEvent.Save) } + ) + } + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + SearchBar( + modifier = Modifier.fillMaxWidth(), + placeHolderTitle = stringResource(CommonStrings.action_search), + query = state.searchQuery, + onQueryChange = { state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(it)) }, + resultState = state.searchResults, + showBackButton = false, + contentPrefix = { + if (state.selectedRooms.isNotEmpty()) { + SelectedRoomsRow( + selectedRooms = state.selectedRooms, + onRemoveRoom = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + }, + ) { rooms -> + LazyColumn { + items(rooms, key = { it.roomId.value }) { roomInfo -> + RoomListItem( + roomInfo = roomInfo, + isSelected = state.selectedRooms.any { it.roomId == roomInfo.roomId }, + onToggle = { state.eventSink(AddRoomToSpaceEvent.ToggleRoom(it)) } + ) + } + } + } + + if (!state.isSearchActive) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.screen_space_add_rooms_room_access_description), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + if (state.selectedRooms.isNotEmpty()) { + SelectedRoomsRow( + selectedRooms = state.selectedRooms, + onRemoveRoom = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + if (state.suggestions.isNotEmpty()) { + LazyColumn { + item { + ListSectionHeader( + title = stringResource(id = CommonStrings.common_suggestions), + hasDivider = true, + ) + } + items(state.suggestions, key = { it.roomId.value }) { roomInfo -> + RoomListItem( + roomInfo = roomInfo, + isSelected = state.selectedRooms.any { it.roomId == roomInfo.roomId }, + onToggle = { state.eventSink(AddRoomToSpaceEvent.ToggleRoom(it)) } + ) + } + } + } + } + } + } + SaveActionView( + saveAction = state.saveAction, + onRetry = { state.eventSink(AddRoomToSpaceEvent.Save) }, + onDismiss = { state.eventSink(AddRoomToSpaceEvent.ResetSaveAction) } + ) +} + +@Composable +private fun SaveActionView( + saveAction: AsyncAction, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + AsyncActionView( + async = saveAction, + onRetry = onRetry, + errorTitle = { + stringResource(CommonStrings.common_something_went_wrong) + }, + errorMessage = { + stringResource(CommonStrings.error_network_or_server_issue) + }, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + ) +} + +@Composable +private fun SelectedRoomsRow( + selectedRooms: ImmutableList, + onRemoveRoom: (SelectRoomInfo) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + items(selectedRooms, key = { it.roomId.value }) { roomInfo -> + SelectedRoom(roomInfo = roomInfo, onRemoveRoom = onRemoveRoom) + } + } +} + +@Composable +private fun RoomListItem( + roomInfo: SelectRoomInfo, + isSelected: Boolean, + onToggle: (SelectRoomInfo) -> Unit, +) { + AvatarListItem( + avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), + avatarType = AvatarType.Room( + heroes = roomInfo.heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) + }.toImmutableList(), + isTombstoned = roomInfo.isTombstoned, + ), + headline = roomInfo.name ?: stringResource(id = CommonStrings.common_no_room_name), + supportingText = roomInfo.canonicalAlias?.value, + trailingContent = ListItemContent.Checkbox(checked = isSelected), + onClick = { onToggle(roomInfo) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun AddRoomToSpaceViewPreview( + @PreviewParameter(AddRoomToSpaceStateProvider::class) state: AddRoomToSpaceState +) = ElementPreview { + AddRoomToSpaceView( + state = state, + onBackClick = {}, + onRoomsAdded = {}, + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt index 240bc89990..f56c3df8c2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -46,6 +46,7 @@ class SpaceNode( fun navigateToSpaceSettings() fun navigateToRoomMemberList() fun startLeaveSpaceFlow() + fun navigateToAddRoom() } private val callback: Callback = callback() @@ -89,6 +90,9 @@ class SpaceNode( onViewMembersClick = { callback.navigateToRoomMemberList() }, + onAddRoomClick = { + callback.navigateToAddRoom() + }, acceptDeclineInviteView = { acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index 695b887496..9f91e0f463 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -98,6 +98,7 @@ fun SpaceView( onLeaveSpaceClick: () -> Unit, onSettingsClick: () -> Unit, onViewMembersClick: () -> Unit, + onAddRoomClick: () -> Unit, modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { @@ -140,6 +141,7 @@ fun SpaceView( onShareSpace = onShareSpace, onViewMembersClick = onViewMembersClick, onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) }, + onAddRoomClick = onAddRoomClick, ) } } @@ -344,6 +346,7 @@ private fun SpaceViewTopBar( onShareSpace: () -> Unit, onViewMembersClick: () -> Unit, onManageRoomsClick: () -> Unit, + onAddRoomClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -376,6 +379,14 @@ private fun SpaceViewTopBar( onDismissRequest = { showMenu = false } ) { if (showManageRoomsAction) { + SpaceMenuItem( + titleRes = CommonStrings.action_add_existing_rooms, + icon = CompoundIcons.Room(), + onClick = { + showMenu = false + onAddRoomClick() + } + ) SpaceMenuItem( titleRes = CommonStrings.action_manage_rooms, icon = CompoundIcons.Edit(), @@ -600,6 +611,7 @@ internal fun SpaceViewPreview( acceptDeclineInviteView = {}, onSettingsClick = {}, onViewMembersClick = {}, + onAddRoomClick = {}, onBackClick = {}, ) } diff --git a/features/space/impl/src/main/res/values-da/translations.xml b/features/space/impl/src/main/res/values-da/translations.xml index 068712629d..8fa612ae43 100644 --- a/features/space/impl/src/main/res/values-da/translations.xml +++ b/features/space/impl/src/main/res/values-da/translations.xml @@ -10,7 +10,13 @@ "Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:" "Forlad %1$s?" "Du er den eneste administrator for %1$s" + "Tilføjelse af et rum påvirker ikke adgangen til rummet. For at ændre adgangen, gå til Rumindstillinger > Sikkerhed og privatliv." "Vis medlemmer" + "Fjernelse af et rum påvirker ikke adgangen til rummet. For at ændre adgangen, gå til Rum-info > Privatliv og sikkerhed." + + "Fjern %1$d rum fra %2$s" + "Fjern %1$d rum fra %2$s" + "Forlad gruppe" "Roller og tilladelser" "Sikkerhed og privatliv" diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml index 1d0238cf7f..e549e1c4bf 100644 --- a/features/space/impl/src/main/res/values-de/translations.xml +++ b/features/space/impl/src/main/res/values-de/translations.xml @@ -10,6 +10,7 @@ "Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:" "%1$s verlassen?" "Du bist der einzige Administrator für %1$s" + "Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" "Mitglieder anzeigen" "Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml index ceeb3125f6..db406bd403 100644 --- a/features/space/impl/src/main/res/values-fr/translations.xml +++ b/features/space/impl/src/main/res/values-fr/translations.xml @@ -10,6 +10,7 @@ "Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:" "Quitter %1$s?" "Vous êtes le seul administrateur de %1$s" + "Ajouter un salon ne changera pas l’accès au salon. Pour modifier l’accès, aller dans les paramètres du salon puis dans Sécurité & confidentialité." "Voir les membres" "Supprimer un salon n’affectera pas ses paramètres d’accès. Pour modifier l’accès, aller dans les settings du salon puis \"Sécurité & confidentialité\"." diff --git a/features/space/impl/src/main/res/values-ru/translations.xml b/features/space/impl/src/main/res/values-ru/translations.xml index f18fa24e5d..f1b314dede 100644 --- a/features/space/impl/src/main/res/values-ru/translations.xml +++ b/features/space/impl/src/main/res/values-ru/translations.xml @@ -11,6 +11,7 @@ "Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:" "Выйти из %1$s?" "Вы единственный администратор для %1$s" + "Добавление комнаты не повлияет на доступ к ней. Чтобы изменить доступ к комнате, перейдите в Настройки > Безопасность и конфиденциальность." "Просмотреть участников" "Удаление комнаты не повлияет на доступ к ней. Чтобы изменить доступ, перейдите в раздел «Информация о комнате > Конфиденциальность и безопасность." diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml index 10aa0fb28c..8d32dc0572 100644 --- a/features/space/impl/src/main/res/values/localazy.xml +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -10,6 +10,7 @@ "You will not be removed from the following room(s) because you\'re the only administrator:" "Leave %1$s?" "You are the only admin for %1$s" + "Adding a room will not affect the room access. To change the access go to Room settings > Security & privacy." "View members" "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security." diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt new file mode 100644 index 0000000000..c0b39c6a69 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt @@ -0,0 +1,332 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.space.impl.addroom + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AddRoomToSpacePresenterTest { + @Test + fun `present - initial state has empty selection and no search`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + assertThat(state.selectedRooms).isEmpty() + assertThat(state.searchQuery).isEmpty() + assertThat(state.isSearchActive).isFalse() + assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(state.canSave).isFalse() + } + } + + @Test + fun `present - ToggleRoom adds room to selection`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val updatedState = awaitItem() + assertThat(updatedState.selectedRooms).hasSize(1) + assertThat(updatedState.selectedRooms.first().roomId).isEqualTo(room.roomId) + assertThat(updatedState.canSave).isTrue() + } + } + + @Test + fun `present - ToggleRoom removes already selected room`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + // Add room + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val stateWithRoom = awaitItem() + assertThat(stateWithRoom.selectedRooms).hasSize(1) + // Remove room + stateWithRoom.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val stateWithoutRoom = awaitItem() + assertThat(stateWithoutRoom.selectedRooms).isEmpty() + assertThat(stateWithoutRoom.canSave).isFalse() + } + } + + @Test + fun `present - UpdateSearchQuery updates query`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test")) + val updatedState = awaitItem() + assertThat(updatedState.searchQuery).isEqualTo("test") + } + } + + @Test + fun `present - OnSearchActiveChanged activates search`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + val updatedState = awaitItem() + assertThat(updatedState.isSearchActive).isTrue() + } + } + + @Test + fun `present - OnSearchActiveChanged deactivates search and clears query`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + // Activate search and set query + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test")) + awaitItem() + // Deactivate search + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false)) + advanceUntilIdle() + val finalState = expectMostRecentItem() + assertThat(finalState.isSearchActive).isFalse() + assertThat(finalState.searchQuery).isEmpty() + } + } + + @Test + fun `present - CloseSearch deactivates and clears query`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + // Activate search and set query + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test")) + awaitItem() + // Close search + state.eventSink(AddRoomToSpaceEvent.CloseSearch) + advanceUntilIdle() + val finalState = expectMostRecentItem() + assertThat(finalState.isSearchActive).isFalse() + assertThat(finalState.searchQuery).isEmpty() + } + } + + @Test + fun `present - searchResults shows Results when rooms available`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createAddRoomToSpacePresenter(roomListService = roomListService) + presenter.test { + awaitItem() // Initial state + // Post rooms to the service + roomListService.postAllRooms( + listOf( + aRoomSummary( + roomId = A_ROOM_ID, + name = "Room 1", + isDirect = false, + isSpace = false, + currentUserMembership = CurrentUserMembership.JOINED, + ) + ) + ) + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + } + } + + @Test + fun `present - searchResults shows NoResultsFound when search active with query but no results`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("nonexistent")) + advanceUntilIdle() + val finalState = expectMostRecentItem() + assertThat(finalState.isSearchActive).isTrue() + assertThat(finalState.searchQuery).isEqualTo("nonexistent") + assertThat(finalState.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) + } + } + + @Test + fun `present - Save triggers addChildToSpace for all selected rooms`() = runTest { + val addChildToSpaceResult = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val spaceService = FakeSpaceService( + addChildToSpaceResult = addChildToSpaceResult, + ) + val presenter = createAddRoomToSpacePresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + // Select two rooms + val room1 = aSelectRoomInfoList()[0] + val room2 = aSelectRoomInfoList()[1] + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room1)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room2)) + awaitItem() + // Save + state.eventSink(AddRoomToSpaceEvent.Save) + // Wait for loading and success states + skipItems(1) // Loading + advanceUntilIdle() + skipItems(1) // Success + // Verify service was called for both rooms + addChildToSpaceResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - Save success updates saveAction to Success`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ) + val presenter = createAddRoomToSpacePresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.Save) + // Wait for loading state + val loadingState = awaitItem() + assertThat(loadingState.saveAction).isEqualTo(AsyncAction.Loading) + // Wait for success state + advanceUntilIdle() + val successState = awaitItem() + assertThat(successState.saveAction).isInstanceOf(AsyncAction.Success::class.java) + } + } + + @Test + fun `present - Save failure updates saveAction to Failure`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + ) + val presenter = createAddRoomToSpacePresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.Save) + // Wait for loading state + val loadingState = awaitItem() + assertThat(loadingState.saveAction).isEqualTo(AsyncAction.Loading) + // Wait for failure state + advanceUntilIdle() + val failureState = awaitItem() + assertThat(failureState.saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - ResetSaveAction resets to Uninitialized`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ) + val presenter = createAddRoomToSpacePresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.Save) + skipItems(1) // Loading + advanceUntilIdle() + val successState = awaitItem() + assertThat(successState.saveAction).isInstanceOf(AsyncAction.Success::class.java) + // Reset + successState.eventSink(AddRoomToSpaceEvent.ResetSaveAction) + val resetState = awaitItem() + assertThat(resetState.saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `canSave is false when no rooms selected`() = runTest { + val presenter = createAddRoomToSpacePresenter() + presenter.test { + val state = awaitItem() + assertThat(state.selectedRooms).isEmpty() + assertThat(state.canSave).isFalse() + } + } + + @Test + fun `canSave is false when loading`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ) + val presenter = createAddRoomToSpacePresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val stateWithRoom = awaitItem() + assertThat(stateWithRoom.canSave).isTrue() + stateWithRoom.eventSink(AddRoomToSpaceEvent.Save) + val loadingState = awaitItem() + assertThat(loadingState.saveAction).isEqualTo(AsyncAction.Loading) + assertThat(loadingState.canSave).isFalse() + } + } + + private fun TestScope.createAddRoomToSpacePresenter( + spaceRoomList: FakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ), + spaceService: FakeSpaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ), + roomListService: FakeRoomListService = FakeRoomListService(), + matrixClient: FakeMatrixClient = FakeMatrixClient( + roomListService = roomListService, + ), + ): AddRoomToSpacePresenter { + val dataSourceFactory = object : AddRoomToSpaceSearchDataSource.Factory { + override fun create(coroutineScope: CoroutineScope) = AddRoomToSpaceSearchDataSource( + coroutineScope = coroutineScope, + roomListService = roomListService, + spaceRoomList = spaceRoomList, + matrixClient = matrixClient, + coroutineDispatchers = testCoroutineDispatchers(), + ) + } + return AddRoomToSpacePresenter( + spaceRoomList = spaceRoomList, + spaceService = spaceService, + dataSourceFactory = dataSourceFactory, + ) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt new file mode 100644 index 0000000000..db322c3687 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt @@ -0,0 +1,123 @@ +/* + * 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.space.impl.addroom + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import kotlinx.collections.immutable.toImmutableList +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AddRoomToSpaceViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking back when search inactive invokes onBackClick`() { + ensureCalledOnce { + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + isSearchActive = false, + ), + onBackClick = it, + ) + rule.pressBack() + } + } + + @Test + fun `clicking back when search active emits CloseSearch event`() { + val eventsRecorder = EventsRecorder() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + isSearchActive = true, + eventSink = eventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertSingle(AddRoomToSpaceEvent.CloseSearch) + } + + @Test + fun `clicking save emits Save event`() { + val eventsRecorder = EventsRecorder() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + selectedRooms = aSelectRoomInfoList().take(1).toImmutableList(), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertList( + listOf( + AddRoomToSpaceEvent.UpdateSearchQuery(""), // SearchBar initialization + AddRoomToSpaceEvent.Save, + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking room in suggestions emits ToggleRoom event`() { + val suggestions = aSelectRoomInfoList() + val eventsRecorder = EventsRecorder() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + suggestions = suggestions, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(suggestions.first().name!!).performClick() + eventsRecorder.assertList( + listOf( + AddRoomToSpaceEvent.UpdateSearchQuery(""), // SearchBar initialization + AddRoomToSpaceEvent.ToggleRoom(suggestions.first()), + ) + ) + } + + @Test + fun `onRoomsAdded called when saveAction is Success`() { + ensureCalledOnce { + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + saveAction = AsyncAction.Success(Unit), + ), + onRoomsAdded = it, + ) + } + } +} + +private fun AndroidComposeTestRule.setAddRoomToSpaceView( + state: AddRoomToSpaceState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomsAdded: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AddRoomToSpaceView( + state = state, + onBackClick = onBackClick, + onRoomsAdded = onRoomsAdded, + ) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt index 27970e93f8..59041a97ed 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt @@ -210,6 +210,7 @@ private fun AndroidComposeTestRule.setSpace onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(), onSettingsClick: () -> Unit = EnsureNeverCalled(), onViewMembersClick: () -> Unit = EnsureNeverCalled(), + onAddRoomClick: () -> Unit = EnsureNeverCalled(), acceptDeclineInviteView: @Composable () -> Unit = {}, ) { setContent { @@ -221,6 +222,7 @@ private fun AndroidComposeTestRule.setSpace onLeaveSpaceClick = onLeaveSpaceClick, onSettingsClick = onSettingsClick, onViewMembersClick = onViewMembersClick, + onAddRoomClick = onAddRoomClick, acceptDeclineInviteView = acceptDeclineInviteView, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt new file mode 100644 index 0000000000..c6a85e9b85 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt @@ -0,0 +1,61 @@ +/* + * 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.libraries.designsystem.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.AvatarType +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * A list item with an Avatar as leading content. + * + * Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1979-1894&m=dev + * + * @param avatarData The data for the avatar. + * @param avatarType The type of avatar to display. + * @param headline The main text of the list item. + * @param modifier The modifier to apply to the list item. + * @param supportingText The supporting text displayed below the headline. + * @param trailingContent The trailing content of the list item. + * @param enabled Whether the list item is enabled. + * @param style The style of the list item. + * @param onClick The callback to invoke when the list item is clicked. + */ +@Composable +fun AvatarListItem( + avatarData: AvatarData, + avatarType: AvatarType, + headline: String, + modifier: Modifier = Modifier, + supportingText: String? = null, + trailingContent: ListItemContent? = null, + enabled: Boolean = true, + style: ListItemStyle = ListItemStyle.Default, + onClick: (() -> Unit)? = null, +) { + ListItem( + modifier = modifier, + headlineContent = { Text(headline) }, + supportingContent = supportingText?.let { @Composable { Text(it) } }, + leadingContent = ListItemContent.Custom { _ -> + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + }, + trailingContent = trailingContent, + style = style, + enabled = enabled, + onClick = onClick, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentRoom.kt new file mode 100644 index 0000000000..a03952194c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentRoom.kt @@ -0,0 +1,33 @@ +/* + * 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.libraries.matrix.api.room.recent + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.RoomInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Returns a [Flow] of [RoomInfo] from recently visited rooms. + * The flow emits items lazily, allowing callers to filter and take only what they need. + * Use [kotlinx.coroutines.flow.take] to limit results and stop iteration early. + * + */ +fun MatrixClient.getRecentlyVisitedRoomInfoFlow( + predicate: (RoomInfo) -> Boolean, +): Flow = flow { + val recentlyVisitedRooms = getRecentlyVisitedRooms().getOrDefault(emptyList()) + for (roomId in recentlyVisitedRooms) { + getRoom(roomId)?.use { room -> + val info = room.info() + if (predicate(info)) { + emit(info) + } + } + } +} 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 927bec13e1..39ea699ae3 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 @@ -23,6 +23,14 @@ interface SpaceService { fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle + /** + * Add a child room to a space. + * @param spaceId The space ID to which the child will be added. + * @param childId The room ID of the child to add. + * @return A result indicating success or failure. + */ + suspend fun addChildToSpace(spaceId: RoomId, childId: RoomId): Result + /** * Remove a child room from a space. * @param spaceId The space ID from which to remove the child. 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 2ce184484c..771da5a3d5 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 @@ -98,6 +98,12 @@ class RustSpaceService( } } + override suspend fun addChildToSpace(spaceId: RoomId, childId: RoomId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService.addChildToSpace(childId = childId.value, spaceId = spaceId.value) + } + } + override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerSpaceService.removeChildFromSpace(childId = childId.value, spaceId = spaceId.value) 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 0cd9907db3..2db83b601a 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,6 +23,7 @@ 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() }, @@ -55,6 +56,10 @@ class FakeSpaceService( return leaveSpaceHandleResult(spaceId) } + override suspend fun addChildToSpace(spaceId: RoomId, childId: RoomId): Result = simulateLongTask { + addChildToSpaceResult(spaceId, childId) + } + override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = simulateLongTask { removeChildFromSpaceResult(spaceId, childId) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt index 49c8415fc7..d6981f62fd 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.RoomInfo import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -32,11 +33,13 @@ data class SelectRoomInfo( ) } -fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo( - roomId = roomId, - name = info.name, - avatarUrl = info.avatarUrl, - heroes = info.heroes, - canonicalAlias = info.canonicalAlias, - isTombstoned = info.successorRoom != null, +fun RoomSummary.toSelectRoomInfo() = info.toSelectRoomInfo() + +fun RoomInfo.toSelectRoomInfo() = SelectRoomInfo( + roomId = id, + name = name, + avatarUrl = avatarUrl, + heroes = heroes, + canonicalAlias = canonicalAlias, + isTombstoned = successorRoom != null, ) diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 60a77143d6..a759af523f 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -477,7 +477,6 @@ Möchtest du wirklich fortfahren?" "In Google Maps öffnen" "In OpenStreetMap öffnen" "Diesen Standort teilen" - "Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" "Von dir erstellte oder beigetretene Spaces." "%1$s • %2$s" "Erstelle einen Space, um Chats zu organisieren" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 6d594b6655..0fcaeeab7d 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -483,7 +483,6 @@ Raison : %1$s." "Ouvrir dans Google Maps" "Ouvrir dans OpenStreetMap" "Partager cette position" - "Ajouter un salon ne changera pas l’accès au salon. Pour modifier l’accès, aller dans les paramètres du salon puis dans Sécurité & confidentialité." "Espaces que vous avez créés ou rejoints." "%1$s • %2$s" "Créer des espaces pour organiser les salons" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 628cce6438..e55f36a559 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -492,7 +492,6 @@ "Открыть в Google Maps" "Открыть в OpenStreetMap" "Поделиться этим местоположением" - "Добавление комнаты не повлияет на доступ к ней. Чтобы изменить доступ к комнате, перейдите в Настройки > Безопасность и конфиденциальность." "Пространства, которые вы создали или к которым присоединились." "%1$s • %2$s" "Создавайте пространства для организации комнат" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 332151e5f8..4bd87f256c 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -484,7 +484,6 @@ Are you sure you want to continue?" "Open in Google Maps" "Open in OpenStreetMap" "Share this location" - "Adding a room will not affect the room access. To change the access go to Room settings > Security & privacy." "Spaces you have created or joined." "%1$s • %2$s" "Create spaces to organize rooms" diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_0_en.png new file mode 100644 index 0000000000..cfcec20e41 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72074f44d83f1772e83de96fd86265a43cdd2123023ac65bee8ddf7abf4af37e +size 38249 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_1_en.png new file mode 100644 index 0000000000..a7e08a1c9f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a295c4f8c9e784861a3db01597be406615a4adb6c4d8997478336e3a3f30003 +size 11682 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_2_en.png new file mode 100644 index 0000000000..2575b9e323 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7c006740c4fd75d7efbd5abe59804d8d5fabbfd7f11b6d14c0b00fcb83850be +size 21434 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_3_en.png new file mode 100644 index 0000000000..a28bfd86a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:841cfff6c0c0219e83e6ee7ca03060e143ddff72f7946e578498ffb6918fa37d +size 14200 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_4_en.png new file mode 100644 index 0000000000..a272355e5b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83d9e5eb832d1f52b17e35e18d1c834120a7b510c2a3cc1647dc35dfeeb8b0a9 +size 42762 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_5_en.png new file mode 100644 index 0000000000..4c049df7e7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d5fd28a7971e2f25fdf6d3e1b0af9a1d279369d2f1d656ff72a36a1badfd774 +size 29089 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_6_en.png new file mode 100644 index 0000000000..d1135ccc34 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d191722630f4eede4606f63053d29996ae32c728af85e198a390be82b84d274 +size 40323 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_0_en.png new file mode 100644 index 0000000000..bf6a20ec99 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd5f99d986f37e962322060add381b334c50a6fbc05a23fa09319b37cc86a090 +size 38078 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_1_en.png new file mode 100644 index 0000000000..489a7dcb1b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e011e95c98c9694980b031207b82c6feb0b9b51b06b233672bee2ee3ceb5b18c +size 11382 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_2_en.png new file mode 100644 index 0000000000..e43d5e7f3d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71bc3b3b49e37690e92fe23ab3c48cc286341e64c427471b7c940b65243bd998 +size 21776 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_3_en.png new file mode 100644 index 0000000000..7917998cd2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76ab4de8587bac55872b2807b342fdbdf72f771aed65ee342705f49a89c1f196 +size 13865 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_4_en.png new file mode 100644 index 0000000000..cb20383fc6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50898da4de06be0d2430d4635b7c6880701d60fb78940bb2135c943e2d9634b5 +size 42908 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_5_en.png new file mode 100644 index 0000000000..b3bab96efb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c4da20d886e0f2fadd57edaea13c2bae4d19fcf4c8a397a2dec92f68e14aad7 +size 27733 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_6_en.png new file mode 100644 index 0000000000..ebbc706877 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.addroom_AddRoomToSpaceView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ceeea93e8cd825cf82971abe76c5a0d28e0522ffa4694db588c87f9473cc117 +size 37422 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index e6ea1df3cb..3a7c568bb0 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -234,7 +234,8 @@ "includeRegex" : [ "screen\\.leave_space\\..*", "screen\\.space_settings\\..*", - "screen\\.space\\..*" + "screen\\.space\\..*", + "screen\\.space_add_rooms\\..*" ] }, {