From 9fe7c50972ef104fb7b92094b10976cb15e3facd Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 20 Jan 2026 22:15:42 +0100 Subject: [PATCH] Implement Space "Add existing rooms" logic and ui --- .../impl/addroom/AddRoomToSpaceEvents.kt | 20 ++ .../space/impl/addroom/AddRoomToSpaceNode.kt | 9 +- .../impl/addroom/AddRoomToSpacePresenter.kt | 171 +++++++++++++ .../addroom/AddRoomToSpaceSearchDataSource.kt | 71 ++++++ .../space/impl/addroom/AddRoomToSpaceState.kt | 26 ++ .../addroom/AddRoomToSpaceStateProvider.kt | 107 ++++++++ .../space/impl/addroom/AddRoomToSpaceView.kt | 228 ++++++++++++++++++ .../components/list/AvatarListItem.kt | 62 +++++ .../matrix/api/room/recent/RecentRoom.kt | 32 +++ .../matrix/ui/model/SelectRoomInfo.kt | 18 +- 10 files changed, 736 insertions(+), 8 deletions(-) create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvents.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceState.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentRoom.kt diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvents.kt new file mode 100644 index 0000000000..5380b08b20 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.addroom + +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo + +sealed interface AddRoomToSpaceEvents { + data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvents + data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvents + data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvents + data object Save : AddRoomToSpaceEvents + data object CloseSearch : AddRoomToSpaceEvents + data object ClearError : AddRoomToSpaceEvents +} 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 index 6d4bced691..939535cd4f 100644 --- 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 @@ -24,6 +24,7 @@ import io.element.android.libraries.architecture.callback class AddRoomToSpaceNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val presenter: AddRoomToSpacePresenter, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun onFinish() @@ -33,6 +34,12 @@ class AddRoomToSpaceNode( @Composable override fun View(modifier: Modifier) { - + val state = presenter.present() + 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..9d89b98263 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.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.produceState +import androidx.compose.runtime.remember +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.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.recent.getRecentRooms +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 io.element.android.libraries.matrix.ui.model.toSelectRoomInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch + +private const val MAX_SUGGESTIONS_COUNT = 5 + +@Inject +class AddRoomToSpacePresenter( + private val spaceRoomList: SpaceRoomList, + private val dataSource: AddRoomToSpaceSearchDataSource, + private val spaceService: SpaceService, + private val matrixClient: MatrixClient, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + + @Composable + override fun present(): AddRoomToSpaceState { + var selectedRooms by remember { mutableStateOf(persistentListOf()) } + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + // Load data source + LaunchedEffect(Unit) { dataSource.load() } + + // Update search query in data source + LaunchedEffect(searchQuery) { + dataSource.setSearchQuery(searchQuery) + } + + // Get rooms already in space + val spaceChildrenIds by remember { + spaceRoomList.spaceRoomsFlow.map { spaceChildren -> + spaceChildren.map { it.roomId } + } + }.collectAsState(initial = emptyList()) + + // Suggestions from recently visited rooms (excluding DMs, spaces, and rooms already in space) + val suggestions by produceState(persistentListOf(), spaceChildrenIds) { + value = matrixClient + .getRecentRooms { info -> + !info.isSpace && !info.isDm && info.currentUserMembership == CurrentUserMembership.JOINED + } + .take(MAX_SUGGESTIONS_COUNT) + .map { info -> info.toSelectRoomInfo() } + .toList() + .toImmutableList() + } + + val allRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) + val searchResults by remember>>> { + derivedStateOf { + val filtered = allRooms.filterNot { it.roomId in spaceChildrenIds } + when { + filtered.isNotEmpty() -> SearchBarResultState.Results(filtered.toImmutableList()) + isSearchActive && searchQuery.isNotEmpty() -> SearchBarResultState.NoResultsFound() + else -> SearchBarResultState.Initial() + } + } + } + + fun handleEvent(event: AddRoomToSpaceEvents) { + when (event) { + is AddRoomToSpaceEvents.ToggleRoom -> { + selectedRooms = if (selectedRooms.any { it.roomId == event.room.roomId }) { + selectedRooms.filterNot { it.roomId == event.room.roomId }.toPersistentList() + } else { + (selectedRooms + event.room).toPersistentList() + } + } + is AddRoomToSpaceEvents.UpdateSearchQuery -> { + searchQuery = event.query + } + is AddRoomToSpaceEvents.OnSearchActiveChanged -> { + isSearchActive = event.active + if (!event.active) { + searchQuery = "" + } + } + AddRoomToSpaceEvents.CloseSearch -> { + isSearchActive = false + searchQuery = "" + } + AddRoomToSpaceEvents.Save -> { + sessionCoroutineScope.addRoomsToSpace( + selectedRooms = selectedRooms, + addAction = saveAction, + ) + } + AddRoomToSpaceEvents.ClearError -> { + 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..48740d1499 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.addroom + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.isDm +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.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.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +private const val PAGE_SIZE = 30 + +/** + * DataSource for rooms that can be added to a space. + * Filters out DMs, spaces, and only includes rooms the user has joined. + */ +@Inject +class AddRoomToSpaceSearchDataSource( + roomListService: RoomListService, + coroutineDispatchers: CoroutineDispatchers, +) { + private val roomList = roomListService.createRoomList( + pageSize = PAGE_SIZE, + initialFilter = RoomListFilter.all(), + source = RoomList.Source.All, + ) + + val roomInfoList: Flow> = roomList.filteredSummaries + .map { roomSummaries -> + roomSummaries + .filter { + it.info.currentUserMembership == CurrentUserMembership.JOINED && + !it.info.isDm && + !it.info.isSpace + } + .distinctBy { it.roomId } + .map { roomSummary -> roomSummary.toSelectRoomInfo() } + .toImmutableList() + } + .flowOn(coroutineDispatchers.computation) + + suspend fun load() = coroutineScope { + roomList.loadAllIncrementally(this) + } + + 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..5bbea067cd --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.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: (AddRoomToSpaceEvents) -> 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..550555135a --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.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")), + ), + ) +} + +private fun anAddRoomToSpaceState( + searchQuery: String = "", + searchResults: SearchBarResultState> = SearchBarResultState.Initial(), + selectedRooms: ImmutableList = persistentListOf(), + isSearchActive: Boolean = false, + saveAction: AsyncAction = AsyncAction.Uninitialized, + suggestions: ImmutableList = persistentListOf(), +): AddRoomToSpaceState { + return AddRoomToSpaceState( + searchQuery = searchQuery, + searchResults = searchResults, + selectedRooms = selectedRooms, + isSearchActive = isSearchActive, + saveAction = saveAction, + suggestions = suggestions, + eventSink = {}, + ) +} + +private 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..cef85255f5 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.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.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.ProgressDialog +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.dialogs.ErrorDialog +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.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(AddRoomToSpaceEvents.ToggleRoom(roomInfo)) + } + + fun onBack() { + if (state.isSearchActive) { + state.eventSink(AddRoomToSpaceEvents.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(AddRoomToSpaceEvents.Save) } + ) + } + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + SearchBar( + modifier = Modifier.fillMaxWidth(), + placeHolderTitle = stringResource(CommonStrings.action_search), + query = state.searchQuery, + onQueryChange = { state.eventSink(AddRoomToSpaceEvents.UpdateSearchQuery(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(AddRoomToSpaceEvents.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(AddRoomToSpaceEvents.ToggleRoom(it)) } + ) + } + } + } + + if (!state.isSearchActive) { + if (state.selectedRooms.isNotEmpty()) { + SelectedRoomsRow( + selectedRooms = state.selectedRooms, + onRemoveRoom = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(8.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(AddRoomToSpaceEvents.ToggleRoom(it)) } + ) + } + } + } + } + } + } + + // Loading dialog + if (state.saveAction.isLoading()) { + ProgressDialog() + } + + // Error dialog + if (state.saveAction.isFailure()) { + ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + onSubmit = { state.eventSink(AddRoomToSpaceEvents.ClearError) }, + ) + } +} + +@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/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..962da7af93 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.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..d35696a8d1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentRoom.kt @@ -0,0 +1,32 @@ +/* + * 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 DM 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.getRecentRooms( + 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/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..cf66d040a9 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,14 @@ 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, ) +