Merge pull request #6063 from element-hq/feature/fga/space_add_existing_room

Implement Space 'Add existing rooms' feature
This commit is contained in:
ganfra
2026-01-22 20:31:13 +01:00
committed by GitHub
43 changed files with 1362 additions and 12 deletions

View File

@@ -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<SpaceNode>(buildContext, listOf(callback))
}
@@ -132,6 +140,14 @@ class SpaceFlowNode(
}
createNode<SpaceSettingsFlowNode>(buildContext, listOf(callback))
}
NavTarget.AddRoom -> {
val callback = object : AddRoomToSpaceNode.Callback {
override fun onFinish() {
backstack.pop()
}
}
createNode<AddRoomToSpaceNode>(buildContext, listOf(callback))
}
}
}

View File

@@ -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
}

View File

@@ -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<Plugin>,
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
)
}
}

View File

@@ -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<AddRoomToSpaceState> {
@Composable
override fun present(): AddRoomToSpaceState {
var selectedRooms: ImmutableList<SelectRoomInfo> by remember { mutableStateOf(persistentListOf()) }
var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(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<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> {
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<SelectRoomInfo>,
addAction: MutableState<AsyncAction<Unit>>,
) = 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)
}
}
}
}

View File

@@ -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<RoomId>) -> Boolean = { info, childIds ->
!info.isSpace &&
!info.isDm &&
info.currentUserMembership == CurrentUserMembership.JOINED &&
info.id !in childIds
}
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries,
spaceChildrenFlow,
) { roomSummaries, childIds ->
roomSummaries
.filter { filterRoomPredicate(it.info, childIds) }
.map { it.toSelectRoomInfo() }
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
val suggestions: Flow<ImmutableList<SelectRoomInfo>> = 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)
}
}

View File

@@ -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<ImmutableList<SelectRoomInfo>>,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val suggestions: ImmutableList<SelectRoomInfo>,
val saveAction: AsyncAction<Unit>,
val eventSink: (AddRoomToSpaceEvent) -> Unit,
) {
val canSave: Boolean = selectedRooms.isNotEmpty() && !saveAction.isLoading()
}

View File

@@ -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<AddRoomToSpaceState> {
override val values: Sequence<AddRoomToSpaceState>
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<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
isSearchActive: Boolean = false,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
suggestions: ImmutableList<SelectRoomInfo> = 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<SelectRoomInfo> = 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()

View File

@@ -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<Unit>,
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<SelectRoomInfo>,
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 = {},
)
}

View File

@@ -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,

View File

@@ -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 = {},
)
}

View File

@@ -10,7 +10,13 @@
<string name="screen_leave_space_subtitle_only_last_admin">"Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:"</string>
<string name="screen_leave_space_title">"Forlad %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Du er den eneste administrator for %1$s"</string>
<string name="screen_space_add_rooms_room_access_description">"Tilføjelse af et rum påvirker ikke adgangen til rummet. For at ændre adgangen, gå til Rumindstillinger &gt; Sikkerhed og privatliv."</string>
<string name="screen_space_menu_action_members">"Vis medlemmer"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Fjernelse af et rum påvirker ikke adgangen til rummet. For at ændre adgangen, gå til Rum-info &gt; Privatliv og sikkerhed."</string>
<plurals name="screen_space_remove_rooms_confirmation_title">
<item quantity="one">"Fjern %1$d rum fra %2$s"</item>
<item quantity="other">"Fjern %1$d rum fra %2$s"</item>
</plurals>
<string name="screen_space_settings_leave_space">"Forlad gruppe"</string>
<string name="screen_space_settings_roles_and_permissions">"Roller og tilladelser"</string>
<string name="screen_space_settings_security_and_privacy">"Sikkerhed og privatliv"</string>

View File

@@ -10,6 +10,7 @@
<string name="screen_leave_space_subtitle_only_last_admin">"Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:"</string>
<string name="screen_leave_space_title">"%1$s verlassen?"</string>
<string name="screen_leave_space_title_last_admin">"Du bist der einzige Administrator für %1$s"</string>
<string name="screen_space_add_rooms_room_access_description">"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\""</string>
<string name="screen_space_menu_action_members">"Mitglieder anzeigen"</string>
<string name="screen_space_remove_rooms_confirmation_content">"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\""</string>
<plurals name="screen_space_remove_rooms_confirmation_title">

View File

@@ -10,6 +10,7 @@
<string name="screen_leave_space_subtitle_only_last_admin">"Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:"</string>
<string name="screen_leave_space_title">"Quitter %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Vous êtes le seul administrateur de %1$s"</string>
<string name="screen_space_add_rooms_room_access_description">"Ajouter un salon ne changera pas laccès au salon. Pour modifier laccès, aller dans les paramètres du salon puis dans Sécurité &amp; confidentialité."</string>
<string name="screen_space_menu_action_members">"Voir les membres"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Supprimer un salon naffectera pas ses paramètres daccès. Pour modifier laccès, aller dans les settings du salon puis \"Sécurité &amp; confidentialité\"."</string>
<plurals name="screen_space_remove_rooms_confirmation_title">

View File

@@ -11,6 +11,7 @@
<string name="screen_leave_space_subtitle_only_last_admin">"Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:"</string>
<string name="screen_leave_space_title">"Выйти из %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Вы единственный администратор для %1$s"</string>
<string name="screen_space_add_rooms_room_access_description">"Добавление комнаты не повлияет на доступ к ней. Чтобы изменить доступ к комнате, перейдите в Настройки &gt; Безопасность и конфиденциальность."</string>
<string name="screen_space_menu_action_members">"Просмотреть участников"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Удаление комнаты не повлияет на доступ к ней. Чтобы изменить доступ, перейдите в раздел «Информация о комнате &gt; Конфиденциальность и безопасность."</string>
<plurals name="screen_space_remove_rooms_confirmation_title">

View File

@@ -10,6 +10,7 @@
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
<string name="screen_space_add_rooms_room_access_description">"Adding a room will not affect the room access. To change the access go to Room settings &gt; Security &amp; privacy."</string>
<string name="screen_space_menu_action_members">"View members"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Removing a room will not affect the room access. To change the access go to Room info &gt; Privacy &amp; security."</string>
<plurals name="screen_space_remove_rooms_confirmation_title">

View File

@@ -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<RoomId, RoomId, Result<Unit>> { _, _ ->
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,
)
}
}

View File

@@ -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<ComponentActivity>()
@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<AddRoomToSpaceEvent>()
rule.setAddRoomToSpaceView(
anAddRoomToSpaceState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertSingle(AddRoomToSpaceEvent.CloseSearch)
}
@Test
fun `clicking save emits Save event`() {
val eventsRecorder = EventsRecorder<AddRoomToSpaceEvent>()
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<AddRoomToSpaceEvent>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddRoomToSpaceView(
state: AddRoomToSpaceState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomsAdded: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AddRoomToSpaceView(
state = state,
onBackClick = onBackClick,
onRoomsAdded = onRoomsAdded,
)
}
}

View File

@@ -210,6 +210,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
onSettingsClick: () -> Unit = EnsureNeverCalled(),
onViewMembersClick: () -> Unit = EnsureNeverCalled(),
onAddRoomClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {},
) {
setContent {
@@ -221,6 +222,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onLeaveSpaceClick = onLeaveSpaceClick,
onSettingsClick = onSettingsClick,
onViewMembersClick = onViewMembersClick,
onAddRoomClick = onAddRoomClick,
acceptDeclineInviteView = acceptDeclineInviteView,
)
}

View File

@@ -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,
)
}

View File

@@ -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<RoomInfo> = flow {
val recentlyVisitedRooms = getRecentlyVisitedRooms().getOrDefault(emptyList())
for (roomId in recentlyVisitedRooms) {
getRoom(roomId)?.use { room ->
val info = room.info()
if (predicate(info)) {
emit(info)
}
}
}
}

View File

@@ -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<Unit>
/**
* Remove a child room from a space.
* @param spaceId The space ID from which to remove the child.

View File

@@ -98,6 +98,12 @@ class RustSpaceService(
}
}
override suspend fun addChildToSpace(spaceId: RoomId, childId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.addChildToSpace(childId = childId.value, spaceId = spaceId.value)
}
}
override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.removeChildFromSpace(childId = childId.value, spaceId = spaceId.value)

View File

@@ -23,6 +23,7 @@ class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
private val addChildToSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
private val joinedParentsResult: (RoomId) -> Result<List<SpaceRoom>> = { 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<Unit> = simulateLongTask {
addChildToSpaceResult(spaceId, childId)
}
override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result<Unit> = simulateLongTask {
removeChildFromSpaceResult(spaceId, childId)
}

View File

@@ -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,
)

View File

@@ -477,7 +477,6 @@ Möchtest du wirklich fortfahren?"</string>
<string name="screen_share_open_google_maps">"In Google Maps öffnen"</string>
<string name="screen_share_open_osm_maps">"In OpenStreetMap öffnen"</string>
<string name="screen_share_this_location_action">"Diesen Standort teilen"</string>
<string name="screen_space_add_rooms_room_access_description">"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\""</string>
<string name="screen_space_list_description">"Von dir erstellte oder beigetretene Spaces."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Erstelle einen Space, um Chats zu organisieren"</string>

View File

@@ -483,7 +483,6 @@ Raison : %1$s."</string>
<string name="screen_share_open_google_maps">"Ouvrir dans Google Maps"</string>
<string name="screen_share_open_osm_maps">"Ouvrir dans OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Partager cette position"</string>
<string name="screen_space_add_rooms_room_access_description">"Ajouter un salon ne changera pas laccès au salon. Pour modifier laccès, aller dans les paramètres du salon puis dans Sécurité &amp; confidentialité."</string>
<string name="screen_space_list_description">"Espaces que vous avez créés ou rejoints."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Créer des espaces pour organiser les salons"</string>

View File

@@ -492,7 +492,6 @@
<string name="screen_share_open_google_maps">"Открыть в Google Maps"</string>
<string name="screen_share_open_osm_maps">"Открыть в OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Поделиться этим местоположением"</string>
<string name="screen_space_add_rooms_room_access_description">"Добавление комнаты не повлияет на доступ к ней. Чтобы изменить доступ к комнате, перейдите в Настройки &gt; Безопасность и конфиденциальность."</string>
<string name="screen_space_list_description">"Пространства, которые вы создали или к которым присоединились."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Создавайте пространства для организации комнат"</string>

View File

@@ -484,7 +484,6 @@ Are you sure you want to continue?"</string>
<string name="screen_share_open_google_maps">"Open in Google Maps"</string>
<string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_space_add_rooms_room_access_description">"Adding a room will not affect the room access. To change the access go to Room settings &gt; Security &amp; privacy."</string>
<string name="screen_space_list_description">"Spaces you have created or joined."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Create spaces to organize rooms"</string>

View File

@@ -234,7 +234,8 @@
"includeRegex" : [
"screen\\.leave_space\\..*",
"screen\\.space_settings\\..*",
"screen\\.space\\..*"
"screen\\.space\\..*",
"screen\\.space_add_rooms\\..*"
]
},
{