Space List

Extract InviteButtonsRowMolecule
Take into account seenSpaceInvites
This commit is contained in:
Benoit Marty 2025-08-19 19:06:05 +02:00 committed by Benoit Marty
parent 1836ae0ae0
commit 81d82fb0de
24 changed files with 811 additions and 40 deletions

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
@ -36,6 +37,7 @@ class HomePresenter(
private val snackbarDispatcher: SnackbarDispatcher,
private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>,
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
@ -46,6 +48,7 @@ class HomePresenter(
val isOnline by syncService.isOnline.collectAsState()
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
val isSpaceFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
}.collectAsState(initial = false)
@ -78,6 +81,7 @@ class HomePresenter(
hasNetworkConnection = isOnline,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,

View file

@ -9,6 +9,7 @@ package io.element.android.features.home.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -20,6 +21,7 @@ data class HomeState(
val hasNetworkConnection: Boolean,
val currentHomeNavigationBarItem: HomeNavigationBarItem,
val roomListState: RoomListState,
val homeSpacesState: HomeSpacesState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,

View file

@ -13,6 +13,8 @@ import io.element.android.features.home.impl.roomlist.RoomListStateProvider
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.roomlist.aRoomsContentState
import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -51,6 +53,7 @@ internal fun aHomeState(
snackbarMessage: SnackbarMessage? = null,
currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
isSpaceFeatureEnabled: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
@ -64,6 +67,7 @@ internal fun aHomeState(
directLogoutState = directLogoutState,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = eventSink,
)

View file

@ -25,7 +25,6 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -50,6 +49,7 @@ import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchView
import io.element.android.features.home.impl.spaces.HomeSpacesView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -61,7 +61,6 @@ import io.element.android.libraries.designsystem.theme.components.NavigationBarI
import io.element.android.libraries.designsystem.theme.components.NavigationBarItem
import io.element.android.libraries.designsystem.theme.components.NavigationBarText
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
@ -262,19 +261,17 @@ private fun HomeScaffold(
)
}
HomeNavigationBarItem.Spaces -> {
Box(
HomeSpacesView(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "Spaces are coming soon!",
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
.hazeSource(state = hazeState),
state = state.homeSpacesState,
onSpaceClick = { spaceId ->
// TODO
}
)
}
}
},

View file

@ -45,15 +45,13 @@ import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
@ -101,7 +99,7 @@ internal fun RoomSummaryRow(
)
}
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRow(
InviteButtonsRowMolecule(
onAcceptClick = {
eventSink(RoomListEvents.AcceptInvite(room))
},
@ -346,31 +344,6 @@ private fun InviteNameAndIndicatorRow(
}
}
@Composable
private fun InviteButtonsRow(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun OnGoingCallIcon(
color: Color,

View file

@ -0,0 +1,23 @@
/*
* 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.home.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.home.impl.spaces.HomeSpacesPresenter
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@Module
interface HomeSpacesModule {
@Binds
fun bindHomeSpacesPresenter(presenter: HomeSpacesPresenter): Presenter<HomeSpacesState>
}

View file

@ -0,0 +1,194 @@
/*
* 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.home.impl.spaces
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun HomeSpaceItemView(
spaceRoom: SpaceRoom,
showUnreadIndicator: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
SpaceScaffoldRow(
modifier = modifier,
spaceRoom = spaceRoom,
onClick = onClick,
onLongClick = { },
) {
NameAndIndicatorRow(
name = spaceRoom.name,
showIndicator = showUnreadIndicator,
)
Spacer(modifier = Modifier.height(1.dp))
if (!spaceRoom.worldReadable) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.LockSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = stringResource(CommonStrings.common_private_space),
fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null },
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(1.dp))
}
val spaceSummary = stringResource(
CommonStrings.screen_space_list_details,
pluralStringResource(CommonPlurals.common_rooms, spaceRoom.childrenCount, spaceRoom.childrenCount),
pluralStringResource(CommonPlurals.common_member_count, spaceRoom.numJoinedMembers, spaceRoom.numJoinedMembers),
)
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = spaceSummary,
fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null },
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (spaceRoom.state == CurrentUserMembership.INVITED) {
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRowMolecule(
onAcceptClick = {},
onDeclineClick = {},
)
}
}
}
@Composable
fun NameAndIndicatorRow(
name: String?,
showIndicator: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (showIndicator) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator
)
}
}
}
@Composable
private fun SpaceScaffoldRow(
spaceRoom: SpaceRoom,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
hideAvatarImage: Boolean,
content: @Composable ColumnScope.() -> Unit
) {
val clickModifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() }
)
.onKeyboardContextMenuAction { onLongClick }
Row(
modifier = modifier
.fillMaxWidth()
.then(clickModifier)
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
) {
Avatar(
avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem),
avatarType = AvatarType.Space(),
hideImage = hideAvatarImage,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
content = content,
)
}
}
@PreviewsDayNight
@Composable
internal fun HomeSpaceItemViewPreview(@PreviewParameter(SpaceRoomProvider::class) spaceRoom: SpaceRoom) = ElementPreview {
HomeSpaceItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = false,
onClick = {},
)
}

View file

@ -0,0 +1,12 @@
/*
* 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.home.impl.spaces
sealed interface HomeSpacesEvents {
}

View file

@ -0,0 +1,44 @@
/*
* 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.home.impl.spaces
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.seenSpaceIds
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class HomeSpacesPresenter @Inject constructor(
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<HomeSpacesState> {
@Composable
override fun present(): HomeSpacesState {
val spaceRooms by client.spaceService.spaceRooms.collectAsState(emptyList())
val seenSpaceInvites by remember {
seenInvitesStore.seenSpaceIds().map { it.toPersistentSet() }
}.collectAsState(persistentSetOf())
fun handleEvents(event: HomeSpacesEvents) {
//when (event) { }
}
return HomeSpacesState(
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites,
eventSink = ::handleEvents,
)
}
}

View file

@ -0,0 +1,18 @@
/*
* 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.home.impl.spaces
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableSet
data class HomeSpacesState(
val spaceRooms: List<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<SpaceId>,
val eventSink: (HomeSpacesEvents) -> Unit,
)

View file

@ -0,0 +1,43 @@
/*
* 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.home.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.toImmutableSet
open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
override val values: Sequence<HomeSpacesState>
get() = sequenceOf(
aHomeSpacesState(
spaceRooms = SpaceRoomProvider().values.toList(),
seenSpaceInvites = setOf(
SpaceId("!spaceId3:example.com"),
).toImmutableSet(),
)
)
}
internal fun aHomeSpacesState(
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
seenSpaceInvites: Set<SpaceId> = emptySet(),
eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState(
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
eventSink = eventSink,
)
fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf(
aSpaceRooms(),
aSpaceRooms(),
aSpaceRooms(),
)
}

View file

@ -0,0 +1,50 @@
/*
* 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.home.impl.spaces
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@Composable
fun HomeSpacesView(
state: HomeSpacesState,
onSpaceClick: (SpaceId) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier) {
state.spaceRooms.forEach {
item(it.spaceId) {
HomeSpaceItemView(
spaceRoom = it,
showUnreadIndicator = it.state == CurrentUserMembership.INVITED && it.spaceId !in state.seenSpaceInvites,
onClick = {
onSpaceClick(it.spaceId)
}
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun HomeSpacesViewPreview(
@PreviewParameter(HomeSpacesStateProvider::class) state: HomeSpacesState,
) = ElementPreview {
HomeSpacesView(
state = state,
onSpaceClick = {},
modifier = Modifier,
)
}

View file

@ -0,0 +1,84 @@
/*
* 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.home.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
override val values: Sequence<SpaceRoom> = sequenceOf(
aSpaceRooms(),
aSpaceRooms(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
spaceId = SpaceId("!spaceId0:example.com"),
),
aSpaceRooms(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
spaceId = SpaceId("!spaceId1:example.com"),
),
aSpaceRooms(
name = null,
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
spaceId = SpaceId("!spaceId2:example.com"),
state = CurrentUserMembership.INVITED,
),
aSpaceRooms(
name = null,
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
spaceId = SpaceId("!spaceId3:example.com"),
state = CurrentUserMembership.INVITED,
),
)
}
fun aSpaceRooms(
name: String? = "Space name",
avatarUrl: String? = null,
canonicalAlias: RoomAlias? = null,
childrenCount: Int = 0,
guestCanJoin: Boolean = false,
heroes: List<MatrixUser> = emptyList(),
joinRule: JoinRule? = null,
numJoinedMembers: Int = 0,
spaceId: SpaceId = SpaceId("!spaceId:example.com"),
roomType: RoomType = RoomType.Space,
state: CurrentUserMembership? = null,
topic: String? = null,
worldReadable: Boolean = false,
) = SpaceRoom(
name = name,
avatarUrl = avatarUrl,
canonicalAlias = canonicalAlias,
childrenCount = childrenCount,
guestCanJoin = guestCanJoin,
heroes = heroes,
joinRule = joinRule,
numJoinedMembers = numJoinedMembers,
spaceId = spaceId,
roomType = roomType,
state = state,
topic = topic,
worldReadable = worldReadable
)

View file

@ -8,7 +8,10 @@
package io.element.android.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.toSpaceId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface SeenInvitesStore {
/**
@ -35,3 +38,9 @@ interface SeenInvitesStore {
*/
suspend fun clear()
}
fun SeenInvitesStore.seenSpaceIds(): Flow<Set<SpaceId>> {
return seenRoomIds().map { roomIds ->
roomIds.map { it.toSpaceId() }.toSet()
}
}

View file

@ -0,0 +1,47 @@
/*
* 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.atomic.molecules
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InviteButtonsRowMolecule(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
modifier: Modifier = Modifier,
declineText: String = stringResource(CommonStrings.action_decline),
acceptText: String = stringResource(CommonStrings.action_accept),
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = declineText,
onClick = onDeclineClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
Button(
text = acceptText,
onClick = onAcceptClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}

View file

@ -17,6 +17,8 @@ enum class AvatarSize(val dp: Dp) {
RoomHeader(96.dp),
RoomListItem(52.dp),
SpaceListItem(52.dp),
RoomSelectRoomListItem(36.dp),
UserPreference(56.dp),

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
@ -47,6 +48,7 @@ interface MatrixClient {
val deviceId: DeviceId
val userProfile: StateFlow<MatrixUser>
val roomListService: RoomListService
val spaceService: SpaceService
val mediaLoader: MatrixMediaLoader
val sessionCoroutineScope: CoroutineScope
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>

View file

@ -20,3 +20,5 @@ value class RoomId(val value: String) : Serializable {
override fun toString(): String = value
}
fun RoomId.toSpaceId(): SpaceId = SpaceId(this.value)

View file

@ -0,0 +1,31 @@
/*
* 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.matrix.api.spaces
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.user.MatrixUser
data class SpaceRoom(
val name: String?,
val avatarUrl: String?,
val canonicalAlias: RoomAlias?,
val childrenCount: Int,
val guestCanJoin: Boolean,
val heroes: List<MatrixUser>,
val joinRule: JoinRule?,
val numJoinedMembers: Int,
val spaceId: SpaceId,
val roomType: RoomType,
val state: CurrentUserMembership?,
val topic: String?,
val worldReadable: Boolean,
)

View file

@ -0,0 +1,15 @@
/*
* 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.matrix.api.spaces
import kotlinx.coroutines.flow.SharedFlow
interface SpaceService {
val spaceRooms: SharedFlow<List<SpaceRoom>>
suspend fun joinedSpaces(): Result<List<SpaceRoom>>
}

View file

@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
@ -71,6 +72,7 @@ import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
@ -143,6 +145,7 @@ class RustMatrixClient(
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val innerRoomListService = innerSyncService.roomListService()
private val innerSpaceService = innerClient.spaceService()
private val rustSyncService = RustSyncService(
inner = innerSyncService,
@ -184,6 +187,17 @@ class RustMatrixClient(
roomSyncSubscriber = roomSyncSubscriber,
)
override val spaceService: SpaceService = RustSpaceService(
innerSpaceService = innerSpaceService,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
//roomListFactory = RoomListFactory(
// innerRoomListService = innerRoomListService,
// sessionCoroutineScope = sessionCoroutineScope,
//),
//roomSyncSubscriber = roomSyncSubscriber,
)
private val verificationService = RustSessionVerificationService(
client = innerClient,
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
@ -540,6 +554,7 @@ class RustMatrixClient(
sessionDelegate.clearCurrentClient()
innerRoomListService.close()
innerSpaceService.close()
notificationService.close()
encryptionService.close()
innerClient.close()

View file

@ -0,0 +1,145 @@
/*
* 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.matrix.impl.spaces
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import org.matrix.rustcomponents.sdk.SpaceServiceInterface
import org.matrix.rustcomponents.sdk.SpaceServiceJoinedSpacesListener
import timber.log.Timber
import org.matrix.rustcomponents.sdk.SpaceService as ClientSpaceService
class RustSpaceService(
private val innerSpaceService: ClientSpaceService,
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
) : SpaceService {
private val mapper = SpaceRoomMapper()
private val mutex = Mutex()
override val spaceRooms = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.joinedSpaces()
.map {
it.let(mapper::map)
}
}
}
// override suspend fun spaceRoomList(spaceId: SpaceId): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
// runCatchingExceptions {
// innerSpaceService.spaceRoomList(spaceId.value)
// }
// }
init {
innerSpaceService
.spaceDiffFlow()
.onEach {
handeUpdate(it)
}
.launchIn(sessionCoroutineScope)
}
private suspend fun handeUpdate(spaceListUpdates: List<SpaceListUpdate>) {
mutex.withLock {
val current = if (spaceRooms.replayCache.isNotEmpty()) {
spaceRooms.first().toMutableList()
} else {
mutableListOf()
}
spaceListUpdates.forEach { update ->
current.applyUpdate(update)
}
spaceRooms.emit(current)
}
}
private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {
when (update) {
is SpaceListUpdate.Append -> {
val newSpaces = update.values.map { it ->
it.let(mapper::map)
}
addAll(newSpaces)
}
SpaceListUpdate.Clear -> clear()
is SpaceListUpdate.Insert -> {
val newSpace = mapper.map(update.value)
add(update.index.toInt(), newSpace)
}
SpaceListUpdate.PopBack -> {
removeAt(lastIndex)
}
SpaceListUpdate.PopFront -> {
removeAt(0)
}
is SpaceListUpdate.PushBack -> {
val newSpace = mapper.map(update.value)
add(newSpace)
}
is SpaceListUpdate.PushFront -> {
val newSpace = mapper.map(update.value)
add(0, newSpace)
}
is SpaceListUpdate.Remove -> {
removeAt(update.index.toInt())
}
is SpaceListUpdate.Reset -> {
clear()
val newSpaces = update.values.map(mapper::map)
addAll(newSpaces)
}
is SpaceListUpdate.Set -> {
val newSpace = mapper.map(update.value)
this[update.index.toInt()] = newSpace
}
is SpaceListUpdate.Truncate -> {
subList(update.length.toInt(), size).clear()
}
}
}
}
internal fun SpaceServiceInterface.spaceDiffFlow(): Flow<List<SpaceListUpdate>> =
callbackFlow {
val listener = object : SpaceServiceJoinedSpacesListener {
override fun onUpdate(roomUpdates: List<SpaceListUpdate>) {
trySendBlocking(roomUpdates)
}
}
Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceDiffFlow}")
val taskHandle = subscribeToJoinedSpaces(listener)
awaitClose {
Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceDiffFlow}")
taskHandle.cancelAndDestroy()
}
}.catch {
Timber.d(it, "spaceDiffFlow() failed")
}.buffer(Channel.UNLIMITED)

View file

@ -0,0 +1,36 @@
/*
* 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.matrix.impl.spaces
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.SpaceRoom as RustSpaceRoom
class SpaceRoomMapper {
fun map(spaceRoom: RustSpaceRoom): SpaceRoom {
return SpaceRoom(
avatarUrl = spaceRoom.avatarUrl,
canonicalAlias = spaceRoom.canonicalAlias?.let(::RoomAlias),
childrenCount = spaceRoom.childrenCount.toInt(),
guestCanJoin = spaceRoom.guestCanJoin,
heroes = spaceRoom.heroes.orEmpty().map { it.map() },
joinRule = spaceRoom.joinRule?.map(),
name = spaceRoom.name,
numJoinedMembers = spaceRoom.numJoinedMembers.toInt(),
spaceId = spaceRoom.roomId.let(::SpaceId),
roomType = spaceRoom.roomType.map(),
state = spaceRoom.state?.map(),
topic = spaceRoom.topic,
worldReadable = spaceRoom.worldReadable.orFalse(),
)
}
}

View file

@ -0,0 +1,19 @@
/*
* 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.matrix.ui.model
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.spaces.SpaceRoom
fun SpaceRoom.getAvatarData(size: AvatarSize) = AvatarData(
id = spaceId.value,
name = name,
url = avatarUrl,
size = size,
)