Merge pull request #5273 from element-hq/feature/bma/spaceNextStep

Space: add content in home screen
This commit is contained in:
Benoit Marty 2025-09-05 17:55:14 +02:00 committed by GitHub
commit a2dd455f22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
146 changed files with 1298 additions and 250 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 dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
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
@BindingContainer
@ContributesTo(SessionScope::class)
interface HomeSpacesModule {
@Binds
fun bindHomeSpacesPresenter(presenter: HomeSpacesPresenter): Presenter<HomeSpacesState>
}

View file

@ -0,0 +1,197 @@
/*
* 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,
hideAvatars: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
SpaceScaffoldRow(
modifier = modifier,
spaceRoom = spaceRoom,
onClick = onClick,
hideAvatars = hideAvatars,
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
private 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,
hideAvatars: Boolean,
modifier: Modifier = Modifier,
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 = hideAvatars,
)
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,
hideAvatars = true,
onClick = {},
)
}

View file

@ -0,0 +1,10 @@
/*
* 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,54 @@
/*
* 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 dev.zacsweers.metro.Inject
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.core.coroutine.mapState
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.map
@Inject
class HomeSpacesPresenter(
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<HomeSpacesState> {
@Composable
override fun present(): HomeSpacesState {
val hideInvitesAvatar by remember {
client
.mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
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(
space = CurrentSpace.Root,
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
eventSink = ::handleEvents,
)
}
}

View file

@ -0,0 +1,25 @@
/*
* 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 space: CurrentSpace,
val spaceRooms: List<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<SpaceId>,
val hideInvitesAvatar: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit,
)
sealed interface CurrentSpace {
object Root : CurrentSpace
data class Space(val spaceRoom: SpaceRoom) : CurrentSpace
}

View file

@ -0,0 +1,53 @@
/*
* 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"),
),
),
aHomeSpacesState(
space = CurrentSpace.Space(
spaceRoom = aSpaceRooms(spaceId = SpaceId("!mySpace:example.com"))
),
spaceRooms = aListOfSpaceRooms(),
),
)
}
internal fun aHomeSpacesState(
space: CurrentSpace = CurrentSpace.Root,
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
seenSpaceInvites: Set<SpaceId> = emptySet(),
hideInvitesAvatar: Boolean = false,
eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState(
space = space,
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
eventSink = eventSink,
)
fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf(
aSpaceRooms(spaceId = SpaceId("!spaceId0:example.com")),
aSpaceRooms(spaceId = SpaceId("!spaceId1:example.com")),
aSpaceRooms(spaceId = SpaceId("!spaceId2:example.com")),
)
}

View file

@ -0,0 +1,80 @@
/*
* 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.components.avatar.AvatarSize
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
import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.toImmutableList
@Composable
fun HomeSpacesView(
state: HomeSpacesState,
onSpaceClick: (SpaceId) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier) {
val space = state.space
when (space) {
CurrentSpace.Root -> {
item {
SpaceHeaderRootView(
numberOfSpaces = state.spaceRooms.size,
// TODO
numberOfRooms = 0,
)
}
}
is CurrentSpace.Space -> item {
SpaceHeaderView(
avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader),
name = space.spaceRoom.name,
topic = space.spaceRoom.topic,
joinRule = space.spaceRoom.joinRule,
heroes = space.spaceRoom.heroes.toImmutableList(),
numberOfMembers = space.spaceRoom.numJoinedMembers,
numberOfRooms = space.spaceRoom.childrenCount,
)
}
}
state.spaceRooms.forEach {
item(it.spaceId) {
val isInvitation = it.state == CurrentUserMembership.INVITED
HomeSpaceItemView(
spaceRoom = it,
showUnreadIndicator = isInvitation && it.spaceId !in state.seenSpaceInvites,
hideAvatars = isInvitation && state.hideInvitesAvatar,
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

@ -12,6 +12,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -158,6 +159,7 @@ class HomePresenterTest {
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = { aHomeSpacesState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
)

View file

@ -0,0 +1,39 @@
/*
* 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class HomeSpacesPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val state = awaitItem()
assertThat(state.space).isEqualTo(CurrentSpace.Root)
assertThat(state.spaceRooms).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.seenSpaceInvites).isEmpty()
}
}
private fun createPresenter(
client: MatrixClient = FakeMatrixClient(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
) = HomeSpacesPresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
}

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

@ -546,7 +546,7 @@ private fun DefaultLoadedContent(
},
memberCount = {
if (contentState.showMemberCount) {
MembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
MembersCountMolecule(memberCount = contentState.numberOfMembers?.toInt() ?: 0)
}
}
)

View file

@ -0,0 +1,46 @@
/*
* 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

@ -26,7 +26,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun MembersCountMolecule(
memberCount: Long,
memberCount: Int,
modifier: Modifier = Modifier,
) {
Row(

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,12 @@ class RustMatrixClient(
roomSyncSubscriber = roomSyncSubscriber,
)
override val spaceService: SpaceService = RustSpaceService(
innerSpaceService = innerSpaceService,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
)
private val verificationService = RustSessionVerificationService(
client = innerClient,
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
@ -540,6 +549,7 @@ class RustMatrixClient(
sessionDelegate.clearCurrentClient()
innerRoomListService.close()
innerSpaceService.close()
notificationService.close()
encryptionService.close()
innerClient.close()

View file

@ -0,0 +1,143 @@
/*
* 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(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

@ -25,6 +25,7 @@ import org.matrix.rustcomponents.sdk.PusherKind
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SpaceService
import org.matrix.rustcomponents.sdk.SyncService
import org.matrix.rustcomponents.sdk.SyncServiceBuilder
import org.matrix.rustcomponents.sdk.TaskHandle
@ -52,6 +53,7 @@ class FakeFfiClient(
override suspend fun cachedAvatarUrl(): String? = null
override suspend fun restoreSession(session: Session) = Unit
override fun syncService(): SyncServiceBuilder = FakeFfiSyncServiceBuilder()
override fun spaceService(): SpaceService = FakeFfiSpaceService()
override fun roomDirectorySearch(): RoomDirectorySearch = FakeFfiRoomDirectorySearch()
override suspend fun setPusher(
identifiers: PusherIdentifiers,

View file

@ -0,0 +1,13 @@
/*
* 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.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.SpaceService
class FakeFfiSpaceService : SpaceService(NoPointer)

View file

@ -30,6 +30,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.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.lambda.lambdaError
@ -65,6 +67,7 @@ class FakeMatrixClient(
private val userDisplayName: String? = A_USER_NAME,
private val userAvatarUrl: String? = AN_AVATAR_URL,
override val roomListService: RoomListService = FakeRoomListService(),
override val spaceService: SpaceService = FakeSpaceService(),
override val mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(),

View file

@ -0,0 +1,32 @@
/*
* 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.test.spaces
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() }
) : SpaceService {
private val _spaceRooms = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRooms: SharedFlow<List<SpaceRoom>>
get() = _spaceRooms.asSharedFlow()
suspend fun emitSpaceRoomList(value: List<SpaceRoom>) {
_spaceRooms.emit(value)
}
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = simulateLongTask {
return joinedSpacesResult()
}
}

View file

@ -0,0 +1,73 @@
/*
* 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.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2048
*/
@Composable
fun SpaceHeaderRootView(
numberOfSpaces: Int,
numberOfRooms: Int,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 24.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.WorkspaceSolid())
)
Text(
text = stringResource(CommonStrings.screen_space_list_title),
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
SpaceInfoRow(
leftText = numberOfSpaces(numberOfSpaces),
rightText = numberOfRooms(numberOfRooms),
)
Text(
text = stringResource(CommonStrings.screen_space_list_description),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
}
}
@PreviewsDayNight
@Composable
internal fun SpaceHeaderRootViewPreview() = ElementPreview {
SpaceHeaderRootView(
numberOfSpaces = 3,
numberOfRooms = 10,
)
}

View file

@ -38,11 +38,11 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
fun SpaceHeaderView(
avatarData: AvatarData,
name: String,
topic: String,
joinRule: JoinRule,
name: String?,
topic: String?,
joinRule: JoinRule?,
heroes: ImmutableList<MatrixUser>,
numberOfMembers: Long,
numberOfMembers: Int,
numberOfRooms: Int,
modifier: Modifier = Modifier,
topicMaxLines: Int = Int.MAX_VALUE,
@ -58,29 +58,35 @@ fun SpaceHeaderView(
avatarData = avatarData,
avatarType = AvatarType.Space(false),
)
Text(
text = name,
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
SpaceInfoRow(
joinRule = joinRule,
numberOfRooms = numberOfRooms,
)
name?.let {
Text(
text = name,
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
}
if (joinRule != null) {
SpaceInfoRow(
joinRule = joinRule,
numberOfRooms = numberOfRooms,
)
}
SpaceMembersView(
heroes = heroes,
numberOfMembers = numberOfMembers,
modifier = Modifier.padding(horizontal = 32.dp),
)
Text(
text = topic,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
maxLines = topicMaxLines,
overflow = TextOverflow.Ellipsis,
)
topic?.let {
Text(
text = topic,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
maxLines = topicMaxLines,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -35,7 +35,7 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun SpaceMembersView(
heroes: ImmutableList<MatrixUser>,
numberOfMembers: Long,
numberOfMembers: Int,
modifier: Modifier = Modifier,
) {
if (heroes.isEmpty()) {
@ -60,7 +60,7 @@ fun SpaceMembersView(
@Composable
private fun SpaceMembersWithAvatar(
heroes: ImmutableList<AvatarData>,
numberOfMembers: Long,
numberOfMembers: Int,
modifier: Modifier = Modifier,
) {
Row(

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a241e70cffe61158784ac75425e13cd8d2695406f6e52a93ca7dddfdc9f99caa
size 14164

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:864ab3319f7074643b58d2dd6da22a4c5493bae25502c89bf2c3503bc57cfe91
size 11628

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba93e44b8732d7bf78b25200a5db6e8a329f970aa1fb6a002033c9ebfd09b805
size 11723

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:59b6f98dc65ae4be92b7cdfde9778b390b6474205f75d48061d6345eece53675
size 19034

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4a8cef8dc61dd71458d7518548e83f5e4484fd9dc740fefc690769ffbf6bd80
size 18949

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ce65c4dd01458194792d0a14eafc66e6e6f0a0e0f59259e4661322d0cf9a46d
size 13819

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3e3765e543cce879b97a35b8c3e1cdef4b6c9ce4b1739b9d9a544d6a56118399
size 11414

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51b283fb8e976c8717abeef61dcf294f3efd52b7291fdf023c09759e6df6513d
size 11337

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c440b9628b8105b73f5f576c1b99269a74a89ab07f56080ad53cc888aca24da3
size 18111

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9ddecc15d26dbdd648ec7abbff51718da03326ebf32a2d675563110c096a198b
size 18141

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dff393d500d855842d84d311b3bb5cb60bab6fc2e8e9efe3b593d675218d5024
size 123012

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04ec1bd0bd667d5319843f480f2b7ca4b1b4364db4fbe22d85c10d291891642a
size 42696

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9993c1937c780b4b7e3a47bfb53fa5735ac7bc20f29559281384ca32eb7c13ae
size 120701

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d00883e68471491d8edf06eee394e4310707dbd03782f57bb7f783822790b61
size 41372

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b819420fc0df9dfd4348dcd95bf46652422307b55bc58283a27130b810f57ce5
size 25671
oid sha256:cd023c99c75dba5987f86951d46e821d378440a35335dc46894ff127951ee50f
size 59135

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f38f42eae4af268bed21fa69b9426708c7544f0ff558bb7bbeda721d5da29755
size 22931
oid sha256:6b9f321dfd59e2a388c0d864c73eca01c29b7f96ee0481597ee3cd8ff1df1781
size 56262

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a4d295ae71ba2709845312b84983b79348069ded46b30091b9fa769a587cbb7
size 14337
oid sha256:26b9908bbd388321444037dcc1aae55037d3dabc7a9f9b14c39ba871f4f9d593
size 16128

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da8f54591a52475c6f47fa083a1bb397e1164dd3e735562388fde4ff45644150
size 16275
oid sha256:1a96b6d95b8941d80035b309444b9eaa038098fb16aa84dec209fc3ee215ac9e
size 21687

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce1aba18ea8a6b45d9c40de9af961952e261c7c6200ab13cdfa67906d995208b
size 14888

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a4d295ae71ba2709845312b84983b79348069ded46b30091b9fa769a587cbb7
size 14337

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da8f54591a52475c6f47fa083a1bb397e1164dd3e735562388fde4ff45644150
size 16275

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:526485d4efe3b100a450885ab3620f180e587cd9fbe6e8a373032db14483a90b
size 19284
oid sha256:98a3562697e9d2d7f60a61976a8261b934dcfb75d195d9cf783f6cce4171dfc1
size 16643

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f52dd1f56c484022be9b2d33c7647f2b4942096fc41928c8021dbb4b60588f81
size 18429
oid sha256:49ca1d5ef80f7572378ef40e7d73b1b669bcac515b3ef54042a7b67f9c3280d7
size 15403

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a1e82c60b70e7755a1e514d69c0da3c7a3ef93e8528f303e9478eb880c2b51f
size 21531
oid sha256:bc5b79a8448cbbd9dd73dc416c8b6d051e335845da2caedcdb9fdc3414c4f9c6
size 20024

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3e85c12d1152986e307ae40753453f5caf789bbfdc00bbc084dbe08f744f4e9
size 17424
oid sha256:526485d4efe3b100a450885ab3620f180e587cd9fbe6e8a373032db14483a90b
size 19284

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2c7116dcf6b9ef6a7281b9dccfdfa92589869e897d7d9781c78c3f422d75533
size 16118
oid sha256:f52dd1f56c484022be9b2d33c7647f2b4942096fc41928c8021dbb4b60588f81
size 18429

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2cb816c3e37b3a8c1e0d31a2d7ec05344e7bd16ccd214bb15475754013f0927d
size 21038
oid sha256:3a1e82c60b70e7755a1e514d69c0da3c7a3ef93e8528f303e9478eb880c2b51f
size 21531

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33e206508e839a7517f0621a79571e52c61b225f57efab0379a9bdb510634351
size 19053
oid sha256:a3e85c12d1152986e307ae40753453f5caf789bbfdc00bbc084dbe08f744f4e9
size 17424

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d896fcf527b98ae449f52bdaa77ef5a230756e3723681afdc549f7aa1ba11c42
size 16867
oid sha256:c2c7116dcf6b9ef6a7281b9dccfdfa92589869e897d7d9781c78c3f422d75533
size 16118

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed5b27869ebf46171cbedb8c57d0163965d77d362445d1edd2ae307371c756ab
size 24687
oid sha256:2cb816c3e37b3a8c1e0d31a2d7ec05344e7bd16ccd214bb15475754013f0927d
size 21038

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ef22b332b3383732def98b137c6986afcc3414e5e7bcf5b568956bd8f1b5c8c
size 14443
oid sha256:33e206508e839a7517f0621a79571e52c61b225f57efab0379a9bdb510634351
size 19053

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ef70634dbed00e908fb43116fcb8d6e2c7084f90f36cca32b83498cf07e1f6a
size 13606
oid sha256:d896fcf527b98ae449f52bdaa77ef5a230756e3723681afdc549f7aa1ba11c42
size 16867

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aaa1497201d95eb9b39dba91326c179f0d2e1a58ea70772067d46e0e7dcc0d0c
size 16677
oid sha256:ed5b27869ebf46171cbedb8c57d0163965d77d362445d1edd2ae307371c756ab
size 24687

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f05acb090eb967a126bb1c4741f763bc7ba254e2d32641d7f6b7d0c3888f5f44
size 16522
oid sha256:2ef22b332b3383732def98b137c6986afcc3414e5e7bcf5b568956bd8f1b5c8c
size 14443

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:408498059aa91ae83ec3617172dd1af1ace78c99b172c3da1895d125965a4bd6
size 15272
oid sha256:2ef70634dbed00e908fb43116fcb8d6e2c7084f90f36cca32b83498cf07e1f6a
size 13606

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:884eb2d69af3e1d91fddcbcd7173ac547530e5f3439ad2edc3df6cdbbc5ac312
size 19914
oid sha256:aaa1497201d95eb9b39dba91326c179f0d2e1a58ea70772067d46e0e7dcc0d0c
size 16677

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:35db9f05aa3c3226c611cd83b9f6f9dd72b70c9b6bb4f68ebb8e0c596b30c99a
size 17591
oid sha256:f05acb090eb967a126bb1c4741f763bc7ba254e2d32641d7f6b7d0c3888f5f44
size 16522

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74de00644b39d999afc93936939e3ff2066f5b9b526d59bad20c50e318ee765e
size 16280
oid sha256:408498059aa91ae83ec3617172dd1af1ace78c99b172c3da1895d125965a4bd6
size 15272

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2e4ac9b255de3602f03a854b8ba71703176253a5c2eeacb364a6c87e89e8f193
size 21200
oid sha256:884eb2d69af3e1d91fddcbcd7173ac547530e5f3439ad2edc3df6cdbbc5ac312
size 19914

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c4335667e4c604526427826e5d56a09dad85a423844f974f55e6ebda17d42a0
size 16617
oid sha256:35db9f05aa3c3226c611cd83b9f6f9dd72b70c9b6bb4f68ebb8e0c596b30c99a
size 17591

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:27df1354079b41fbed84791777d9e54198c3576418f6d5027b4b88ff22454584
size 14821
oid sha256:74de00644b39d999afc93936939e3ff2066f5b9b526d59bad20c50e318ee765e
size 16280

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad1df854d799ccb28358aa5a7c61a7ec4627b6b766813c9d3a3a6b7d423c5a1a
size 21401
oid sha256:2e4ac9b255de3602f03a854b8ba71703176253a5c2eeacb364a6c87e89e8f193
size 21200

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d07f11e284078387bf9ab84e6b79fd35371486ba79fca7ccdfde6e316d7549f
size 14371
oid sha256:2c4335667e4c604526427826e5d56a09dad85a423844f974f55e6ebda17d42a0
size 16617

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05090ff82f6f6fe27e9a2986b47c4aae72c28c0335532534d9a4b65b00613d24
size 13634
oid sha256:27df1354079b41fbed84791777d9e54198c3576418f6d5027b4b88ff22454584
size 14821

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:732c17b7a00cfde8e52aaefe9b02bc009ec840d2cf72adbbd9bbab010cc2b12b
size 16295
oid sha256:ad1df854d799ccb28358aa5a7c61a7ec4627b6b766813c9d3a3a6b7d423c5a1a
size 21401

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:005c5f7cf69f2452d6fe894e02ae030aa2d6692181b0e94552b33064e755fbec
size 14999
oid sha256:2d07f11e284078387bf9ab84e6b79fd35371486ba79fca7ccdfde6e316d7549f
size 14371

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e9c435ebe2e1c7b729c96e75ffa57e488b157a0e7632be7123cb0f4542770a1d
size 14259
oid sha256:05090ff82f6f6fe27e9a2986b47c4aae72c28c0335532534d9a4b65b00613d24
size 13634

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00680313760f229b844a3109530e9c0691b3b02917b3f5e18ac0c8b07b09724c
size 16914
oid sha256:732c17b7a00cfde8e52aaefe9b02bc009ec840d2cf72adbbd9bbab010cc2b12b
size 16295

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:989b61bfa802cbf66168c6484366b85d0f683416c0b8ad6c2c59b7a45ed19313
size 15436
oid sha256:005c5f7cf69f2452d6fe894e02ae030aa2d6692181b0e94552b33064e755fbec
size 14999

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fe2ac65f4be2460a563a07cc64e7b592efb850c9a8d72c26adf6d326bdd200d9
size 15094
oid sha256:e9c435ebe2e1c7b729c96e75ffa57e488b157a0e7632be7123cb0f4542770a1d
size 14259

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33b2f35b4be757434e86bce5062551a5ef5af4760016c1751b984e333342841f
size 16373
oid sha256:00680313760f229b844a3109530e9c0691b3b02917b3f5e18ac0c8b07b09724c
size 16914

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fd18a15bc49e87b8abc9231a13771a0fe34003b11fe1fd48df2d91f54bc40cc8
size 15564
oid sha256:989b61bfa802cbf66168c6484366b85d0f683416c0b8ad6c2c59b7a45ed19313
size 15436

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:388a1ac9f1790fd305fd329b73ae621093d323db3fa4d4d32eef92f8dd5b51ec
size 14824
oid sha256:fe2ac65f4be2460a563a07cc64e7b592efb850c9a8d72c26adf6d326bdd200d9
size 15094

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc90e78d2fa94425322895028f1156bfda196ee2a761231920c6ffd80f02984b
size 17512
oid sha256:33b2f35b4be757434e86bce5062551a5ef5af4760016c1751b984e333342841f
size 16373

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74fda6437495995876f76dc1ff0e056de4937ef8d719b3be1ce73baccd31516e
size 15889
oid sha256:fd18a15bc49e87b8abc9231a13771a0fe34003b11fe1fd48df2d91f54bc40cc8
size 15564

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04fb2f230a09f0d3e27e151d98aa5d91e416b5ca9637b4db5d8e00118a68e7c4
size 15166
oid sha256:388a1ac9f1790fd305fd329b73ae621093d323db3fa4d4d32eef92f8dd5b51ec
size 14824

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:896f698ef11d8a0577baf0a81cb54cabc307d95d05c230e5e3bdde40c3dc0900
size 17844
oid sha256:bc90e78d2fa94425322895028f1156bfda196ee2a761231920c6ffd80f02984b
size 17512

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf264b6f8af5b7432511d595ff8c663c5e2be33c9f85268627f5188e3f0f8db0
size 18949
oid sha256:74fda6437495995876f76dc1ff0e056de4937ef8d719b3be1ce73baccd31516e
size 15889

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0bbe1e3e8e1ea119cc61509617d3fa8e2bd047f619c41271901c73c46be1d610
size 18201
oid sha256:04fb2f230a09f0d3e27e151d98aa5d91e416b5ca9637b4db5d8e00118a68e7c4
size 15166

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a7c183808801645e285dfba563c036c204a347de20b4b1e40fcfeab29fafb7d
size 20876
oid sha256:896f698ef11d8a0577baf0a81cb54cabc307d95d05c230e5e3bdde40c3dc0900
size 17844

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a48eb99f4466e3a883c0c7441006f201e0db22fd0e920ad0a77a8512637e01bb
size 16445
oid sha256:cf264b6f8af5b7432511d595ff8c663c5e2be33c9f85268627f5188e3f0f8db0
size 18949

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:895ba9a35b8854d5005fc0671a7e6cbba5e26525b4a8ed4fa0fb612432caa04e
size 15205
oid sha256:0bbe1e3e8e1ea119cc61509617d3fa8e2bd047f619c41271901c73c46be1d610
size 18201

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98baed819cd8b08085ceabf7dbccccc77b0fdf0d28a3f852879f6f8aa02ee441
size 19848
oid sha256:7a7c183808801645e285dfba563c036c204a347de20b4b1e40fcfeab29fafb7d
size 20876

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4678c15b608547c255d5eeb128c144ae7a3c5a21de2b047fc77f15364822ac2
size 12847
oid sha256:a48eb99f4466e3a883c0c7441006f201e0db22fd0e920ad0a77a8512637e01bb
size 16445

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e30395594c1a35d90fc0a04af340eb79cbe686d90856414d0024b769f86e89d
size 12507
oid sha256:895ba9a35b8854d5005fc0671a7e6cbba5e26525b4a8ed4fa0fb612432caa04e
size 15205

Some files were not shown because too many files have changed in this diff Show more