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

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