Implement Space "Add existing rooms" logic and ui

This commit is contained in:
ganfra
2026-01-20 22:15:42 +01:00
parent e840671bf2
commit 9fe7c50972
10 changed files with 736 additions and 8 deletions

View File

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

View File

@@ -24,6 +24,7 @@ import io.element.android.libraries.architecture.callback
class AddRoomToSpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
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
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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