Implement Space "Add existing rooms" logic and ui
This commit is contained in:
parent
e840671bf2
commit
9fe7c50972
10 changed files with 736 additions and 8 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.architecture.callback
|
||||||
class AddRoomToSpaceNode(
|
class AddRoomToSpaceNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: AddRoomToSpacePresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onFinish()
|
fun onFinish()
|
||||||
|
|
@ -33,6 +34,12 @@ class AddRoomToSpaceNode(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
AddRoomToSpaceView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = ::navigateUp,
|
||||||
|
onRoomsAdded = callback::onFinish,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<AddRoomToSpaceState> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): AddRoomToSpaceState {
|
||||||
|
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(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<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> {
|
||||||
|
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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ImmutableList<SelectRoomInfo>> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ImmutableList<SelectRoomInfo>>,
|
||||||
|
val selectedRooms: ImmutableList<SelectRoomInfo>,
|
||||||
|
val suggestions: ImmutableList<SelectRoomInfo>,
|
||||||
|
val saveAction: AsyncAction<Unit>,
|
||||||
|
val eventSink: (AddRoomToSpaceEvents) -> Unit,
|
||||||
|
) {
|
||||||
|
val canSave: Boolean = selectedRooms.isNotEmpty() && !saveAction.isLoading()
|
||||||
|
}
|
||||||
|
|
@ -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<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")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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(),
|
||||||
|
): AddRoomToSpaceState {
|
||||||
|
return AddRoomToSpaceState(
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
searchResults = searchResults,
|
||||||
|
selectedRooms = selectedRooms,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
saveAction = saveAction,
|
||||||
|
suggestions = suggestions,
|
||||||
|
eventSink = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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()
|
||||||
|
|
@ -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<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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<RoomInfo> = flow {
|
||||||
|
val recentlyVisitedRooms = getRecentlyVisitedRooms().getOrDefault(emptyList())
|
||||||
|
for (roomId in recentlyVisitedRooms) {
|
||||||
|
getRoom(roomId)?.use { room ->
|
||||||
|
val info = room.info()
|
||||||
|
if (predicate(info)) {
|
||||||
|
emit(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
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.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.roomlist.RoomSummary
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -32,11 +33,14 @@ data class SelectRoomInfo(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo(
|
fun RoomSummary.toSelectRoomInfo() = info.toSelectRoomInfo()
|
||||||
roomId = roomId,
|
|
||||||
name = info.name,
|
fun RoomInfo.toSelectRoomInfo() = SelectRoomInfo(
|
||||||
avatarUrl = info.avatarUrl,
|
roomId = id,
|
||||||
heroes = info.heroes,
|
name = name,
|
||||||
canonicalAlias = info.canonicalAlias,
|
avatarUrl = avatarUrl,
|
||||||
isTombstoned = info.successorRoom != null,
|
heroes = heroes,
|
||||||
|
canonicalAlias = canonicalAlias,
|
||||||
|
isTombstoned = successorRoom != null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue