diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 2d08d5f6f5..97fff8ee5c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -60,6 +60,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint @@ -334,7 +335,7 @@ class LoggedInFlowNode( .build() } is NavTarget.Room -> { - val callback = object : JoinedRoomLoadedFlowNode.Callback { + val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback { override fun onOpenRoom(roomId: RoomId, serverNames: List) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) } @@ -373,6 +374,11 @@ class LoggedInFlowNode( backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) } } + val spaceCallback = object : SpaceEntryPoint.Callback { + override fun onOpenRoom(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) + } + } val inputs = RoomFlowNode.Inputs( roomIdOrAlias = navTarget.roomIdOrAlias, roomDescription = Optional.ofNullable(navTarget.roomDescription), @@ -380,7 +386,7 @@ class LoggedInFlowNode( trigger = Optional.ofNullable(navTarget.trigger), initialElement = navTarget.initialElement ) - createNode(buildContext, plugins = listOf(inputs, callback)) + createNode(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback)) } is NavTarget.UserProfile -> { val callback = object : UserProfileEntryPoint.Callback { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 12d9df78cf..f2df6ee8c9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -30,7 +30,9 @@ import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs @@ -72,6 +74,7 @@ class RoomFlowNode( private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val syncService: SyncService, private val membershipObserver: RoomMembershipObserver, + private val spaceEntryPoint: SpaceEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Loading, @@ -106,6 +109,9 @@ class RoomFlowNode( @Parcelize data class JoinedRoom(val roomId: RoomId) : NavTarget + + @Parcelize + data class Space(val spaceId: RoomId) : NavTarget } override fun onBuilt() { @@ -146,17 +152,7 @@ class RoomFlowNode( when (membership) { CurrentUserMembership.JOINED -> { if (isSpace) { - // It should not happen, but probably due to an issue in the sliding sync, - // we can have a space here in case the space has just been joined. - // So navigate to the JoinRoom target for now, which will - // handle the space not supported screen - backstack.newRoot( - NavTarget.JoinRoom( - roomId = roomId, - serverNames = serverNames, - trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite, - ) - ) + backstack.newRoot(NavTarget.Space(spaceId = roomId)) } else { backstack.newRoot(NavTarget.JoinedRoom(roomId)) } @@ -194,7 +190,7 @@ class RoomFlowNode( ) } } - val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias) + val params = Params(navTarget.roomAlias) roomAliasResolverEntryPoint.nodeBuilder(this, buildContext) .callback(callback) .params(params) @@ -218,6 +214,13 @@ class RoomFlowNode( ) createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) } + is NavTarget.Space -> { + val spaceCallback = plugins().single() + spaceEntryPoint.nodeBuilder(this, buildContext) + .inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId)) + .callback(spaceCallback) + .build() + } } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 57fefaf413..f0ebe6c02d 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.haze.materials) implementation(projects.features.reportroom.api) implementation(projects.features.changeroommemberroles.api) + implementation(projects.libraries.previewutils) api(projects.features.home.api) testImplementation(libs.androidx.compose.ui.test.junit) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6d531504ab..37727712fb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -269,7 +269,7 @@ private fun HomeScaffold( .hazeSource(state = hazeState), state = state.homeSpacesState, onSpaceClick = { spaceId -> - // TODO + onRoomClick(spaceId) } ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 9c77ab816a..b8e299c5e9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -36,7 +36,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -44,6 +43,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.push.api.battery.BatteryOptimizationState @@ -101,12 +101,7 @@ class RoomListPresenter( var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } // Avatar indicator - val hideInvitesAvatar by remember { - client - .mediaPreviewService() - .mediaPreviewConfigFlow - .mapState { config -> config.hideInviteAvatar } - }.collectAsState() + val hideInvitesAvatar by client.rememberHideInvitesAvatar() val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt index 29ceca14bf..dea6defc0a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt @@ -13,10 +13,9 @@ 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 io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.map @@ -28,15 +27,10 @@ class HomeSpacesPresenter( ) : Presenter { @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 hideInvitesAvatar by client.rememberHideInvitesAvatar() + val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList()) val seenSpaceInvites by remember { - seenInvitesStore.seenSpaceIds().map { it.toPersistentSet() } + seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } }.collectAsState(persistentSetOf()) fun handleEvents(event: HomeSpacesEvents) { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt index 6def46c7b3..96733991f9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt @@ -7,14 +7,14 @@ package io.element.android.features.home.impl.spaces -import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import kotlinx.collections.immutable.ImmutableSet data class HomeSpacesState( val space: CurrentSpace, val spaceRooms: List, - val seenSpaceInvites: ImmutableSet, + val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, val eventSink: (HomeSpacesEvents) -> Unit, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt index 466f515b26..921c340886 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt @@ -8,8 +8,9 @@ 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.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom import kotlinx.collections.immutable.toImmutableSet open class HomeSpacesStateProvider : PreviewParameterProvider { @@ -18,12 +19,12 @@ open class HomeSpacesStateProvider : PreviewParameterProvider { aHomeSpacesState( spaceRooms = SpaceRoomProvider().values.toList(), seenSpaceInvites = setOf( - SpaceId("!spaceId3:example.com"), + RoomId("!spaceId3:example.com"), ), ), aHomeSpacesState( space = CurrentSpace.Space( - spaceRoom = aSpaceRooms(spaceId = SpaceId("!mySpace:example.com")) + spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com")) ), spaceRooms = aListOfSpaceRooms(), ), @@ -33,7 +34,7 @@ open class HomeSpacesStateProvider : PreviewParameterProvider { internal fun aHomeSpacesState( space: CurrentSpace = CurrentSpace.Root, spaceRooms: List = aListOfSpaceRooms(), - seenSpaceInvites: Set = emptySet(), + seenSpaceInvites: Set = emptySet(), hideInvitesAvatar: Boolean = false, eventSink: (HomeSpacesEvents) -> Unit = {}, ) = HomeSpacesState( @@ -46,8 +47,8 @@ internal fun aHomeSpacesState( fun aListOfSpaceRooms(): List { return listOf( - aSpaceRooms(spaceId = SpaceId("!spaceId0:example.com")), - aSpaceRooms(spaceId = SpaceId("!spaceId1:example.com")), - aSpaceRooms(spaceId = SpaceId("!spaceId2:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId0:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId1:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId2:example.com")), ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index be6639e94b..8b07b9f526 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -14,17 +14,18 @@ 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.core.RoomId 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.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.model.getAvatarData import kotlinx.collections.immutable.toImmutableList @Composable fun HomeSpacesView( state: HomeSpacesState, - onSpaceClick: (SpaceId) -> Unit, + onSpaceClick: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier) { @@ -51,15 +52,18 @@ fun HomeSpacesView( ) } } - state.spaceRooms.forEach { - item(it.spaceId) { - val isInvitation = it.state == CurrentUserMembership.INVITED - HomeSpaceItemView( - spaceRoom = it, - showUnreadIndicator = isInvitation && it.spaceId !in state.seenSpaceInvites, + state.spaceRooms.forEach { spaceRoom -> + item(spaceRoom.roomId) { + val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED + SpaceRoomItemView( + spaceRoom = spaceRoom, + showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { - onSpaceClick(it.spaceId) + onSpaceClick(spaceRoom.roomId) + }, + onLongClick = { + // TODO } ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt index 29c18c78a0..474e08293a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt @@ -8,77 +8,44 @@ 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.core.RoomId 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 +import io.element.android.libraries.previewutils.room.aSpaceRoom class SpaceRoomProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( - aSpaceRooms(), - aSpaceRooms( + aSpaceRoom(), + aSpaceRoom( numJoinedMembers = 5, childrenCount = 10, worldReadable = true, - spaceId = SpaceId("!spaceId0:example.com"), + roomId = RoomId("!spaceId0:example.com"), ), - aSpaceRooms( + aSpaceRoom( numJoinedMembers = 5, childrenCount = 10, worldReadable = true, avatarUrl = "anUrl", - spaceId = SpaceId("!spaceId1:example.com"), + roomId = RoomId("!spaceId1:example.com"), ), - aSpaceRooms( + aSpaceRoom( name = null, numJoinedMembers = 5, childrenCount = 10, worldReadable = true, avatarUrl = "anUrl", - spaceId = SpaceId("!spaceId2:example.com"), + roomId = RoomId("!spaceId2:example.com"), state = CurrentUserMembership.INVITED, ), - aSpaceRooms( + aSpaceRoom( name = null, numJoinedMembers = 5, childrenCount = 10, worldReadable = true, avatarUrl = "anUrl", - spaceId = SpaceId("!spaceId3:example.com"), + roomId = RoomId("!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 = 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 -) diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt index 6c609e3810..682970ffe7 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt @@ -8,10 +8,7 @@ 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 { /** @@ -38,9 +35,3 @@ interface SeenInvitesStore { */ suspend fun clear() } - -fun SeenInvitesStore.seenSpaceIds(): Flow> { - return seenRoomIds().map { roomIds -> - roomIds.map { it.toSpaceId() }.toSet() - } -} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index 04e5b904e6..5fe12a42d9 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -34,7 +34,6 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState -import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -50,6 +49,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Optional @@ -93,12 +93,7 @@ class JoinRoomPresenter( val forgetRoomAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } var knockMessage by rememberSaveable { mutableStateOf("") } var isDismissingContent by remember { mutableStateOf(false) } - val hideInviteAvatars by remember { - matrixClient - .mediaPreviewService() - .mediaPreviewConfigFlow - .mapState { config -> config.hideInviteAvatar } - }.collectAsState() + val hideInviteAvatars by matrixClient.rememberHideInvitesAvatar() val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() } val contentState by produceState( diff --git a/features/space/api/build.gradle.kts b/features/space/api/build.gradle.kts new file mode 100644 index 0000000000..dd19efefec --- /dev/null +++ b/features/space/api/build.gradle.kts @@ -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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.space.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt new file mode 100644 index 0000000000..88cbbad5dd --- /dev/null +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId + +interface SpaceEntryPoint : FeatureEntryPoint { + fun nodeBuilder( + parentNode: Node, + buildContext: BuildContext, + ): NodeBuilder + + interface NodeBuilder { + fun inputs(inputs: Inputs): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + data class Inputs( + val roomId: RoomId + ) : Plugin + + interface Callback : Plugin { + fun onOpenRoom(roomId: RoomId) + } +} diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts new file mode 100644 index 0000000000..b41f8ebc93 --- /dev/null +++ b/features/space/impl/build.gradle.kts @@ -0,0 +1,57 @@ +import extension.setupDependencyInjection + +/* + * 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. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.space.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.deeplink.api) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(projects.libraries.featureflag.api) + implementation(projects.features.invite.api) + implementation(projects.libraries.previewutils) + api(projects.features.space.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.features.invite.test) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt new file mode 100644 index 0000000000..1ef8275b27 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +@Inject +class DefaultSpaceEntryPoint : SpaceEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SpaceEntryPoint.NodeBuilder { + val plugins = mutableSetOf() + return object : SpaceEntryPoint.NodeBuilder { + override fun inputs(inputs: SpaceEntryPoint.Inputs): SpaceEntryPoint.NodeBuilder { + plugins.add(inputs) + return this + } + + override fun callback(callback: SpaceEntryPoint.Callback): SpaceEntryPoint.NodeBuilder { + plugins.add(callback) + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins = plugins.toList()) + } + } + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt new file mode 100644 index 0000000000..848dac3ebc --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt @@ -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.space.impl + +sealed interface SpaceEvents { + data object LoadMore : SpaceEvents +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt new file mode 100644 index 0000000000..d37be4c76c --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceNode.kt @@ -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.space.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.Inject +import io.element.android.annotations.ContributesNode +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@Inject +class SpaceNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SpacePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + private val inputs = plugins.filterIsInstance().single() + private val callback = plugins.filterIsInstance().single() + private val presenter = presenterFactory.create(inputs) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SpaceView( + state = state, + onBackClick = ::navigateUp, + onRoomClick = { roomId -> + callback.onOpenRoom(roomId) + }, + modifier = modifier + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt new file mode 100644 index 0000000000..95597371a6 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.Inject +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@Inject +class SpacePresenter( + @Assisted private val inputs: SpaceEntryPoint.Inputs, + private val client: MatrixClient, + private val seenInvitesStore: SeenInvitesStore, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter + } + + private val spaceRoomList = client.spaceService.spaceRoomList(inputs.roomId) + + @Composable + override fun present(): SpaceState { + LaunchedEffect(Unit) { + paginate() + } + val hideInvitesAvatar by client.rememberHideInvitesAvatar() + val seenSpaceInvites by remember { + seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } + }.collectAsState(persistentSetOf()) + + val coroutineScope = rememberCoroutineScope() + val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList()) + val hasMoreToLoad by remember { + spaceRoomList.paginationStatusFlow.mapState { status -> + when (status) { + is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad + SpaceRoomList.PaginationStatus.Loading -> true + } + } + }.collectAsState() + + val currentSpace by remember { spaceRoomList.currentSpaceFlow() }.collectAsState(null) + + fun handleEvents(event: SpaceEvents) { + when (event) { + SpaceEvents.LoadMore -> coroutineScope.paginate() + } + } + return SpaceState( + currentSpace = currentSpace, + children = children.toPersistentList(), + seenSpaceInvites = seenSpaceInvites, + hideInvitesAvatar = hideInvitesAvatar, + hasMoreToLoad = hasMoreToLoad, + eventSink = ::handleEvents, + ) + } + + private fun CoroutineScope.paginate() = launch { + spaceRoomList.paginate() + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt new file mode 100644 index 0000000000..ad822283ca --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class SpaceState( + val currentSpace: SpaceRoom?, + val children: ImmutableList, + val seenSpaceInvites: ImmutableSet, + val hideInvitesAvatar: Boolean, + val hasMoreToLoad: Boolean, + val eventSink: (SpaceEvents) -> Unit +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt new file mode 100644 index 0000000000..36c2ab62b0 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet + +open class SpaceStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceState(), + aSpaceState(hasMoreToLoad = true), + aSpaceState( + hasMoreToLoad = true, + children = aListOfSpaceRooms(), + ), + aSpaceState( + hasMoreToLoad = false, + children = aListOfSpaceRooms() + ) + // Add other states here + ) +} + +fun aSpaceState( + parentSpace: SpaceRoom? = aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + roomId = RoomId("!spaceId0:example.com"), + ), + children: List = emptyList(), + seenSpaceInvites: Set = emptySet(), + hideInvitesAvatar: Boolean = false, + hasMoreToLoad: Boolean = false, +) = SpaceState( + currentSpace = parentSpace, + children = children.toImmutableList(), + seenSpaceInvites = seenSpaceInvites.toImmutableSet(), + hideInvitesAvatar = hideInvitesAvatar, + hasMoreToLoad = hasMoreToLoad, + eventSink = {} +) + +private fun aListOfSpaceRooms(): List { + return listOf( + aSpaceRoom(roomId = RoomId("!spaceId0:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId1:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId2:example.com")), + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt new file mode 100644 index 0000000000..f3bdf7379b --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +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.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +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.components.SpaceHeaderView +import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun SpaceView( + state: SpaceState, + onBackClick: () -> Unit, + onRoomClick: (roomId: RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + SpaceViewTopBar(currentSpace = state.currentSpace, onBackClick = onBackClick) + }, + content = { padding -> + Box( + modifier = Modifier.padding(padding) + ) { + SpaceViewContent( + state = state, + onRoomClick = onRoomClick + ) + } + }, + ) +} + +@Composable +private fun SpaceViewContent( + state: SpaceState, + onRoomClick: (roomId: RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier.fillMaxSize()) { + val currentSpace = state.currentSpace + if (currentSpace != null) { + item { + SpaceHeaderView( + avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), + name = currentSpace.name, + topic = currentSpace.topic, + joinRule = currentSpace.joinRule, + heroes = currentSpace.heroes.toImmutableList(), + numberOfMembers = currentSpace.numJoinedMembers, + numberOfRooms = currentSpace.childrenCount, + ) + } + } + state.children.forEach { spaceRoom -> + item { + val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED + SpaceRoomItemView( + spaceRoom = spaceRoom, + showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, + hideAvatars = isInvitation && state.hideInvitesAvatar, + onClick = { + onRoomClick(spaceRoom.roomId) + }, + onLongClick = { + // TODO + } + ) + } + } + if (state.hasMoreToLoad) { + item { + LoadingMoreIndicator(eventSink = state.eventSink) + } + } + } +} + +@Composable +private fun LoadingMoreIndicator( + eventSink: (SpaceEvents) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(Unit) { + latestEventSink(SpaceEvents.LoadMore) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpaceViewTopBar( + currentSpace: SpaceRoom?, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + if (currentSpace != null) { + SpaceAvatarAndNameRow( + name = currentSpace.name, + avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom), + ) + } + }, + actions = { + }, + ) +} + +@Composable +private fun SpaceAvatarAndNameRow( + name: String?, + avatarData: AvatarData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(), + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + text = name ?: stringResource(CommonStrings.common_no_room_name), + style = ElementTheme.typography.fontBodyLgMedium, + fontStyle = FontStyle.Italic.takeIf { name == null }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceViewPreview( + @PreviewParameter(SpaceStateProvider::class) state: SpaceState +) = ElementPreview { + SpaceView( + state = state, + onRoomClick = {}, + onBackClick = {}, + ) +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt new file mode 100644 index 0000000000..0bcd1303ae --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt @@ -0,0 +1,167 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.space.impl + +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.features.space.api.SpaceEntryPoint +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpacePresenterTest { + @Test + fun `present - initial state`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + paginateResult = paginateResult, + ) + }, + ), + ), + ) + presenter.test { + val state = awaitItem() + assertThat(state.currentSpace).isNull() + assertThat(state.children).isEmpty() + assertThat(state.seenSpaceInvites).isEmpty() + assertThat(state.hideInvitesAvatar).isFalse() + assertThat(state.hasMoreToLoad).isTrue() + advanceUntilIdle() + paginateResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - load more`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + paginateResult = paginateResult, + ) + }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + paginateResult.assertions().isCalledOnce() + state.eventSink(SpaceEvents.LoadMore) + advanceUntilIdle() + paginateResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - has more to load value`() = runTest { + val fakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { fakeSpaceRoomList }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.hasMoreToLoad).isTrue() + fakeSpaceRoomList.emitPaginationStatus( + SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false) + ) + assertThat(awaitItem().hasMoreToLoad).isFalse() + fakeSpaceRoomList.emitPaginationStatus( + SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true) + ) + assertThat(awaitItem().hasMoreToLoad).isTrue() + } + } + + @Test + fun `present - current space value`() = runTest { + val fakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { fakeSpaceRoomList }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.currentSpace).isNull() + val aSpace = aSpaceRoom() + fakeSpaceRoomList.emitCurrentSpace(aSpace) + assertThat(awaitItem().currentSpace).isEqualTo(aSpace) + } + } + + @Test + fun `present - children value`() = runTest { + val fakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + client = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { fakeSpaceRoomList }, + ), + ), + ) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.children).isEmpty() + val aSpace = aSpaceRoom() + fakeSpaceRoomList.emitSpaceRooms(listOf(aSpace)) + assertThat(awaitItem().children).containsExactly(aSpace) + } + } + + private fun createSpacePresenter( + inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID), + client: MatrixClient = FakeMatrixClient(), + seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + ): SpacePresenter { + return SpacePresenter( + inputs = inputs, + client = client, + seenInvitesStore = seenInvitesStore, + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt index 1fab64020d..8d339ae704 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -20,5 +20,3 @@ value class RoomId(val value: String) : Serializable { override fun toString(): String = value } - -fun RoomId.toSpaceId(): SpaceId = SpaceId(this.value) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt index ce697089c7..d4e1d57826 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt @@ -8,7 +8,7 @@ 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.core.RoomId 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 @@ -23,9 +23,11 @@ data class SpaceRoom( val heroes: List, val joinRule: JoinRule?, val numJoinedMembers: Int, - val spaceId: SpaceId, + val roomId: RoomId, val roomType: RoomType, val state: CurrentUserMembership?, val topic: String?, val worldReadable: Boolean, -) +) { + val isSpace = roomType == RoomType.Space +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt new file mode 100644 index 0000000000..a591fc2537 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -0,0 +1,24 @@ +/* + * 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.Flow +import kotlinx.coroutines.flow.StateFlow + +interface SpaceRoomList { + sealed interface PaginationStatus { + data object Loading : PaginationStatus + data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus + } + + fun currentSpaceFlow(): Flow + + val spaceRoomsFlow: Flow> + val paginationStatusFlow: StateFlow + suspend fun paginate(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 58494502f7..b4572ad0bb 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -7,9 +7,12 @@ package io.element.android.libraries.matrix.api.spaces +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.SharedFlow interface SpaceService { - val spaceRooms: SharedFlow> + val spaceRoomsFlow: SharedFlow> suspend fun joinedSpaces(): Result> + + fun spaceRoomList(id: RoomId): SpaceRoomList } diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index a0fa346be2..757d7aa1fc 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.previewutils) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt new file mode 100644 index 0000000000..e3f2132a2f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -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.libraries.matrix.impl.spaces + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList + +class RustSpaceRoomList( + private val roomId: RoomId, + private val innerProvider: suspend () -> InnerSpaceRoomList, + sessionCoroutineScope: CoroutineScope, + spaceRoomMapper: SpaceRoomMapper, + private val spaceRoomCache: SpaceRoomCache, +) : SpaceRoomList { + private val inner = CompletableDeferred() + + override fun currentSpaceFlow(): Flow { + return spaceRoomCache.getSpaceRoomFlow(roomId) + } + + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + override val paginationStatusFlow: MutableStateFlow = + MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache + ) + + init { + sessionCoroutineScope.launch { + inner.complete(innerProvider()) + } + sessionCoroutineScope.launch { + inner.await().paginationStateFlow() + .onEach { paginationStatus -> + paginationStatusFlow.emit(paginationStatus.into()) + } + .collect() + } + + sessionCoroutineScope.launch { + inner.await().spaceListUpdateFlow() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) + } + .collect() + } + } + + override suspend fun paginate(): Result { + return runCatchingExceptions { + inner.await().paginate() + } + } + + private fun SpaceRoomListPaginationState.into(): SpaceRoomList.PaginationStatus { + return when (this) { + is SpaceRoomListPaginationState.Idle -> SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = !endReached) + SpaceRoomListPaginationState.Loading -> SpaceRoomList.PaginationStatus.Loading + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index b059d476bc..2a63367577 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -8,7 +8,9 @@ package io.element.android.libraries.matrix.impl.spaces import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import kotlinx.coroutines.CoroutineDispatcher @@ -21,11 +23,8 @@ 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 @@ -38,104 +37,55 @@ class RustSpaceService( private val sessionCoroutineScope: CoroutineScope, private val sessionDispatcher: CoroutineDispatcher, ) : SpaceService { - private val mapper = SpaceRoomMapper() - private val mutex = Mutex() - - override val spaceRooms = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + private val spaceRoomMapper = SpaceRoomMapper() + private val spaceRoomCache = SpaceRoomCache() + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache + ) override suspend fun joinedSpaces(): Result> = withContext(sessionDispatcher) { runCatchingExceptions { innerSpaceService.joinedSpaces() .map { - it.let(mapper::map) + it.let(spaceRoomMapper::map) } } } - // override suspend fun spaceRoomList(spaceId: SpaceId): Result> = withContext(sessionDispatcher) { - // runCatchingExceptions { - // innerSpaceService.spaceRoomList(spaceId.value) - // } - // } + override fun spaceRoomList(id: RoomId): SpaceRoomList { + return RustSpaceRoomList( + roomId = id, + innerProvider = { innerSpaceService.spaceRoomList(id.value) }, + sessionCoroutineScope = sessionCoroutineScope, + spaceRoomMapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache, + ) + } init { innerSpaceService - .spaceDiffFlow() - .onEach { - handeUpdate(it) + .spaceListUpdate() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) } .launchIn(sessionCoroutineScope) } - - private suspend fun handeUpdate(spaceListUpdates: List) { - 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.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> = +internal fun SpaceServiceInterface.spaceListUpdate(): Flow> = callbackFlow { val listener = object : SpaceServiceJoinedSpacesListener { override fun onUpdate(roomUpdates: List) { trySendBlocking(roomUpdates) } } - Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceDiffFlow}") + Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}") val taskHandle = subscribeToJoinedSpaces(listener) awaitClose { - Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceDiffFlow}") + Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}") taskHandle.cancelAndDestroy() } }.catch { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt new file mode 100644 index 0000000000..f1193661ea --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2023, 2024 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.matrix.api.spaces.SpaceRoom +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import timber.log.Timber + +internal class SpaceListUpdateProcessor( + private val spaceRoomsFlow: MutableSharedFlow>, + private val mapper: SpaceRoomMapper, + private val spaceRoomCache: SpaceRoomCache, +) { + private val mutex = Mutex() + + suspend fun postUpdates(updates: List) { + Timber.v("Update space rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") + updateSpaceRooms { + updates.forEach { update -> applyUpdate(update) } + } + } + + private suspend fun updateSpaceRooms(block: MutableList.() -> Unit) = + mutex.withLock { + val spaceRooms = if (spaceRoomsFlow.replayCache.isNotEmpty()) { + spaceRoomsFlow.first().toMutableList() + } else { + mutableListOf() + } + block(spaceRooms) + spaceRoomCache.update(spaceRooms) + spaceRoomsFlow.emit(spaceRooms) + } + + private fun MutableList.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() + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt new file mode 100644 index 0000000000..8b7d1f5e1b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCache.kt @@ -0,0 +1,35 @@ +/* + * 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.coroutine.mapState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * An in memory cache of space rooms. + * Only caches Rooms with roomType [io.element.android.libraries.matrix.api.room.RoomType.Space]. + */ +class SpaceRoomCache { + private val inMemoryCache = MutableStateFlow>(emptyMap()) + fun getSpaceRoomFlow(roomId: RoomId): Flow { + return inMemoryCache.mapState { it[roomId] } + } + + fun update(spaceRooms: List) { + inMemoryCache.update { currentValues -> + val newValues = spaceRooms + .filter { it.isSpace } + .associateBy { it.roomId } + currentValues + newValues + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt new file mode 100644 index 0000000000..3d1a002e4d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt @@ -0,0 +1,57 @@ +/* + * 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.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener +import org.matrix.rustcomponents.sdk.SpaceRoomListInterface +import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener +import timber.log.Timber +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState + +internal fun SpaceRoomListInterface.paginationStateFlow(): Flow = callbackFlow { + val listener = object : SpaceRoomListPaginationStateListener { + override fun onUpdate(paginationState: SpaceRoomListPaginationState) { + trySend(paginationState) + } + } + // Send the initial value + trySend(paginationState()) + // Then subscribe to updates + val result = subscribeToPaginationStateUpdates(listener) + awaitClose { + result.cancelAndDestroy() + } +}.catch { + Timber.d(it, "paginationStateFlow() failed") +}.buffer(Channel.UNLIMITED) + +internal fun SpaceRoomListInterface.spaceListUpdateFlow(): Flow> = + callbackFlow { + val listener = object : SpaceRoomListEntriesListener { + override fun onUpdate(rooms: List) { + trySendBlocking(rooms) + } + } + Timber.d("Open spaceListUpdateFlow for SpaceRoomListInterface ${this@spaceListUpdateFlow}") + val taskHandle = subscribeToRoomUpdate(listener) + awaitClose { + Timber.d("Close spaceListUpdateFlow for SpaceRoomListInterface ${this@spaceListUpdateFlow}") + taskHandle.cancelAndDestroy() + } + }.catch { + Timber.d(it, "spaceListUpdateFlow() failed") + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt index 5622ee2c8c..c217f941c7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt @@ -9,7 +9,7 @@ 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.core.RoomId 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 @@ -26,7 +26,7 @@ class SpaceRoomMapper { joinRule = spaceRoom.joinRule?.map(), name = spaceRoom.name, numJoinedMembers = spaceRoom.numJoinedMembers.toInt(), - spaceId = spaceRoom.roomId.let(::SpaceId), + roomId = RoomId(spaceRoom.roomId), roomType = spaceRoom.roomType.map(), state = spaceRoom.state?.map(), topic = spaceRoom.topic, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt new file mode 100644 index 0000000000..c699ef8097 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt @@ -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.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import org.matrix.rustcomponents.sdk.JoinRule +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomHero +import org.matrix.rustcomponents.sdk.RoomType +import org.matrix.rustcomponents.sdk.SpaceRoom + +fun aRustSpaceRoom( + roomId: RoomId = A_ROOM_ID, + canonicalAlias: String? = null, + name: String? = null, + topic: String? = null, + avatarUrl: String? = null, + roomType: RoomType = RoomType.Space, + numJoinedMembers: ULong = 0uL, + joinRule: JoinRule? = null, + worldReadable: Boolean? = null, + guestCanJoin: Boolean = false, + childrenCount: ULong = 0uL, + state: Membership? = null, + heroes: List = emptyList(), +) = SpaceRoom( + roomId = roomId.value, + canonicalAlias = canonicalAlias, + name = name, + topic = topic, + avatarUrl = avatarUrl, + roomType = roomType, + numJoinedMembers = numJoinedMembers, + joinRule = joinRule, + worldReadable = worldReadable, + guestCanJoin = guestCanJoin, + childrenCount = childrenCount, + state = state, + heroes = heroes, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt new file mode 100644 index 0000000000..171afd49fc --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt @@ -0,0 +1,58 @@ +/* + * 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 io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import org.matrix.rustcomponents.sdk.NoPointer +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import org.matrix.rustcomponents.sdk.SpaceRoom +import org.matrix.rustcomponents.sdk.SpaceRoomList +import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener +import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener +import org.matrix.rustcomponents.sdk.TaskHandle +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState + +class FakeFfiSpaceRoomList( + private val paginateResult: () -> Unit = { lambdaError() }, + private val paginationStateResult: () -> SpaceRoomListPaginationState = { lambdaError() }, + private val roomsResult: () -> List = { lambdaError() }, +) : SpaceRoomList(NoPointer) { + private var spaceRoomListPaginationStateListener: SpaceRoomListPaginationStateListener? = null + private var spaceRoomListEntriesListener: SpaceRoomListEntriesListener? = null + + override suspend fun paginate() = simulateLongTask { + paginateResult() + } + + override fun paginationState(): SpaceRoomListPaginationState { + return paginationStateResult() + } + + override fun rooms(): List { + return roomsResult() + } + + override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle { + spaceRoomListPaginationStateListener = listener + return FakeFfiTaskHandle() + } + + fun triggerPaginationStateUpdate(state: SpaceRoomListPaginationState) { + spaceRoomListPaginationStateListener?.onUpdate(state) + } + + override fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle { + spaceRoomListEntriesListener = listener + return FakeFfiTaskHandle() + } + + fun triggerRoomListUpdate(rooms: List) { + spaceRoomListEntriesListener?.onUpdate(rooms) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt new file mode 100644 index 0000000000..47c3a19a6b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt @@ -0,0 +1,190 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.SpaceListUpdate + +class RoomSummaryListProcessorTest { + private val spaceRoomsFlow = MutableStateFlow>(emptyList()) + + @Test + fun `Append adds new entries at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + + val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID_2) + processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry, newEntry, newEntry)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(4) + assertThat(spaceRoomsFlow.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue() + } + + @Test + fun `PushBack adds a new entry at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + processor.postUpdates(listOf(SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value.last().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `PushFront inserts a new entry at the start of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + processor.postUpdates(listOf(SpaceListUpdate.PushFront(aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value.first().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Set replaces an entry at some index`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Set(index.toUInt(), aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Insert inserts a new entry at the provided index`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Insert(index.toUInt(), aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Remove removes an entry at some index`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Remove(index.toUInt()))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `PopBack removes an entry at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.PopBack)) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Test + fun `PopFront removes an entry at the start of the list`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.PopFront)) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Test + fun `Clear removes all the entries`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + + processor.postUpdates(listOf(SpaceListUpdate.Clear)) + + assertThat(spaceRoomsFlow.value).isEmpty() + } + + @Test + fun `Truncate removes all entries after the provided length`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Truncate(1u))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Test + fun `Reset removes all entries and add the provided ones`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Reset(listOf(aRustSpaceRoom(A_ROOM_ID_3))))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_3) + } + + @Test + fun `When there is no replay cache SpaceListUpdateProcessor starts with an empty list`() = runTest { + val spaceRoomsSharedFlow = MutableSharedFlow>(replay = 1) + val processor = createProcessor( + spaceRoomsFlow = spaceRoomsSharedFlow, + ) + assertThat(spaceRoomsSharedFlow.replayCache).isEmpty() + val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID) + processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry)))) + assertThat(spaceRoomsSharedFlow.replayCache).hasSize(1) + assertThat(spaceRoomsSharedFlow.replayCache.first()).hasSize(1) + } + + private fun createProcessor( + spaceRoomsFlow: MutableSharedFlow> = this.spaceRoomsFlow, + ) = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = SpaceRoomMapper(), + spaceRoomCache = SpaceRoomCache(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt new file mode 100644 index 0000000000..6ef3259657 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt @@ -0,0 +1,118 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.spaces + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList + +class RustSpaceRoomListTest { + @Test + fun `paginationStatusFlow emits values`() = runTest { + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginationStateResult = { SpaceRoomListPaginationState.Idle(false) } + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.paginationStatusFlow.test { + // First value is the initial one + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + // First value after the subscription occurs + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Loading) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Loading) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(true)) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(false)) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)) + } + } + + @Test + fun `spaceRoomsFlow emits values`() = runTest { + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginationStateResult = { SpaceRoomListPaginationState.Idle(false) } + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.spaceRoomsFlow.test { + // Give time for the subscription to be set + runCurrent() + innerSpaceRoomList.triggerRoomListUpdate( + listOf( + SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2)) + ) + ) + val rooms = awaitItem() + assertThat(rooms).hasSize(1) + assertThat(rooms[0].roomId).isEqualTo(A_ROOM_ID_2) + } + } + + @Test + fun `paginate invokes paginate on the inner class`() = runTest { + val paginateResult = lambdaRecorder { } + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginateResult = paginateResult, + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.paginate() + paginateResult.assertions().isCalledOnce() + } + + @Test + fun `currentSpaceFlow reads value from the SpaceRoomCache`() = runTest { + val spaceRoomCache = SpaceRoomCache() + val sut = createRustSpaceRoomList( + spaceRoomCache = spaceRoomCache, + ) + sut.currentSpaceFlow().test { + assertThat(awaitItem()).isNull() + val spaceRoom = aSpaceRoom(roomId = A_ROOM_ID) + spaceRoomCache.update(listOf(spaceRoom)) + assertThat(awaitItem()).isEqualTo(spaceRoom) + } + } + + private fun TestScope.createRustSpaceRoomList( + roomId: RoomId = A_ROOM_ID, + innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(), + innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList }, + spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(), + spaceRoomCache: SpaceRoomCache = SpaceRoomCache(), + ): RustSpaceRoomList { + return RustSpaceRoomList( + roomId = roomId, + innerProvider = innerProvider, + sessionCoroutineScope = backgroundScope, + spaceRoomMapper = spaceRoomMapper, + spaceRoomCache = spaceRoomCache, + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt new file mode 100644 index 0000000000..0679ce1a33 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomCacheTest.kt @@ -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.matrix.impl.spaces + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpaceRoomCacheTest { + @Test + fun `getSpaceRoomFlow emits items`() = runTest { + val sut = SpaceRoomCache() + sut.getSpaceRoomFlow(A_ROOM_ID).test { + assertThat(awaitItem()).isNull() + val room = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + sut.update(listOf(room)) + // Not a space, should not be cached + expectNoEvents() + val space = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Space, + ) + sut.update(listOf(space)) + assertThat(awaitItem()).isEqualTo(space) + val spaceOther = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Space, + ) + sut.update(listOf(spaceOther)) + expectNoEvents() + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt new file mode 100644 index 0000000000..cd5bf7eb53 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt @@ -0,0 +1,49 @@ +/* + * 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.SpaceRoomList +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeSpaceRoomList( + initialSpaceFlowValue: SpaceRoom? = null, + initialSpaceRoomsValue: List = emptyList(), + initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, + private val paginateResult: () -> Result = { lambdaError() }, +) : SpaceRoomList { + private val currentSpaceMutableStateFlow: MutableStateFlow = MutableStateFlow(initialSpaceFlowValue) + override fun currentSpaceFlow(): Flow = currentSpaceMutableStateFlow.asStateFlow() + + fun emitCurrentSpace(value: SpaceRoom?) { + currentSpaceMutableStateFlow.value = value + } + + private val _spaceRoomsFlow: MutableStateFlow> = MutableStateFlow(initialSpaceRoomsValue) + override val spaceRoomsFlow: Flow> = _spaceRoomsFlow.asStateFlow() + + fun emitSpaceRooms(value: List) { + _spaceRoomsFlow.value = value + } + + private val _paginationStatusFlow: MutableStateFlow = MutableStateFlow(initialSpaceRoomList) + override val paginationStatusFlow: StateFlow = _paginationStatusFlow.asStateFlow() + + fun emitPaginationStatus(value: SpaceRoomList.PaginationStatus) { + _paginationStatusFlow.value = value + } + + override suspend fun paginate(): Result = simulateLongTask { + paginateResult() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt index 54005b122b..43cc8ae8d2 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -7,7 +7,9 @@ package io.element.android.libraries.matrix.test.spaces +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -16,17 +18,22 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow class FakeSpaceService( - private val joinedSpacesResult: () -> Result> = { lambdaError() } + private val joinedSpacesResult: () -> Result> = { lambdaError() }, + private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, ) : SpaceService { - private val _spaceRooms = MutableSharedFlow>() - override val spaceRooms: SharedFlow> - get() = _spaceRooms.asSharedFlow() + private val _spaceRoomsFlow = MutableSharedFlow>() + override val spaceRoomsFlow: SharedFlow> + get() = _spaceRoomsFlow.asSharedFlow() suspend fun emitSpaceRoomList(value: List) { - _spaceRooms.emit(value) + _spaceRoomsFlow.emit(value) } override suspend fun joinedSpaces(): Result> = simulateLongTask { return joinedSpacesResult() } + + override fun spaceRoomList(id: RoomId): SpaceRoomList { + return spaceRoomListResult(id) + } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt similarity index 66% rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt index 461c550d8e..be540d486d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpaceItemView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.home.impl.spaces +package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -22,88 +22,66 @@ 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.ReadOnlyComposable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector 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.AvatarData 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.room.join.JoinRule 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( +fun SpaceRoomItemView( spaceRoom: SpaceRoom, showUnreadIndicator: Boolean, hideAvatars: Boolean, onClick: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier, ) { - SpaceScaffoldRow( + SpaceRoomItemScaffold( modifier = modifier, - spaceRoom = spaceRoom, - onClick = onClick, + avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem), + isSpace = spaceRoom.isSpace, hideAvatars = hideAvatars, - onLongClick = { }, + onClick = onClick, + onLongClick = onLongClick, ) { NameAndIndicatorRow( name = spaceRoom.name, - showIndicator = showUnreadIndicator, + 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), + SubtitleRow( + visibilityIcon = spaceRoom.visibilityIcon(), + subtitle = spaceRoom.subtitle() ) + Spacer(modifier = Modifier.height(1.dp)) Text( modifier = Modifier.weight(1f), style = ElementTheme.typography.fontBodyMdRegular, - text = spaceSummary, + text = spaceRoom.info(), fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null }, color = ElementTheme.colors.textSecondary, maxLines = 1, @@ -119,6 +97,37 @@ internal fun HomeSpaceItemView( } } +@Composable +private fun SubtitleRow( + visibilityIcon: ImageVector?, + subtitle: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (visibilityIcon != null) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(end = 4.dp), + imageVector = visibilityIcon, + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + Text( + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitle, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + @Composable private fun NameAndIndicatorRow( name: String?, @@ -148,8 +157,9 @@ private fun NameAndIndicatorRow( } @Composable -private fun SpaceScaffoldRow( - spaceRoom: SpaceRoom, +private fun SpaceRoomItemScaffold( + avatarData: AvatarData, + isSpace: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, hideAvatars: Boolean, @@ -173,8 +183,8 @@ private fun SpaceScaffoldRow( .height(IntrinsicSize.Min), ) { Avatar( - avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem), - avatarType = AvatarType.Space(), + avatarData = avatarData, + avatarType = if (isSpace) AvatarType.Space() else AvatarType.Room(), hideImage = hideAvatars, ) Spacer(modifier = Modifier.width(16.dp)) @@ -185,13 +195,39 @@ private fun SpaceScaffoldRow( } } -@PreviewsDayNight @Composable -internal fun HomeSpaceItemViewPreview(@PreviewParameter(SpaceRoomProvider::class) spaceRoom: SpaceRoom) = ElementPreview { - HomeSpaceItemView( - spaceRoom = spaceRoom, - showUnreadIndicator = false, - hideAvatars = true, - onClick = {}, - ) +@ReadOnlyComposable +private fun SpaceRoom.subtitle(): String { + return if (isSpace) { + if (joinRule == JoinRule.Public) { + stringResource(CommonStrings.common_public_space) + } else { + stringResource(CommonStrings.common_private_space) + } + } else { + pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers) + } +} + +@Composable +@ReadOnlyComposable +private fun SpaceRoom.info(): String { + return if (isSpace) { + stringResource( + CommonStrings.screen_space_list_details, + pluralStringResource(CommonPlurals.common_rooms, childrenCount, childrenCount), + pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers), + ) + } else { + topic.orEmpty() + } +} + +@Composable +private fun SpaceRoom.visibilityIcon(): ImageVector? { + return if (joinRule == JoinRule.Public) { + CompoundIcons.Public() + } else { + CompoundIcons.LockSolid() + } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt index afe03b6877..e4b056fdea 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt @@ -12,7 +12,7 @@ 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, + id = roomId.value, name = name, url = avatarUrl, size = size, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt new file mode 100644 index 0000000000..0eb2b37ea4 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt @@ -0,0 +1,24 @@ +/* + * 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.safety + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient + +@Composable +fun MatrixClient.rememberHideInvitesAvatar(): State { + return remember { + mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() +} diff --git a/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt new file mode 100644 index 0000000000..3acea6255b --- /dev/null +++ b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt @@ -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.previewutils.room + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.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 + +fun aSpaceRoom( + name: String? = "Space name", + avatarUrl: String? = null, + canonicalAlias: RoomAlias? = null, + childrenCount: Int = 0, + guestCanJoin: Boolean = false, + heroes: List = emptyList(), + joinRule: JoinRule? = null, + numJoinedMembers: Int = 0, + roomId: RoomId = RoomId("!roomId: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, + roomId = roomId, + roomType = roomType, + state = state, + topic = topic, + worldReadable = worldReadable +) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png deleted file mode 100644 index 4dc4712e67..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a241e70cffe61158784ac75425e13cd8d2695406f6e52a93ca7dddfdc9f99caa -size 14164 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png deleted file mode 100644 index dc4f56f39b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:864ab3319f7074643b58d2dd6da22a4c5493bae25502c89bf2c3503bc57cfe91 -size 11628 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png deleted file mode 100644 index 403a96cbad..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ba93e44b8732d7bf78b25200a5db6e8a329f970aa1fb6a002033c9ebfd09b805 -size 11723 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png deleted file mode 100644 index f12b559ded..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:59b6f98dc65ae4be92b7cdfde9778b390b6474205f75d48061d6345eece53675 -size 19034 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png deleted file mode 100644 index 833415fba7..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b4a8cef8dc61dd71458d7518548e83f5e4484fd9dc740fefc690769ffbf6bd80 -size 18949 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png deleted file mode 100644 index 175a0f0151..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7ce65c4dd01458194792d0a14eafc66e6e6f0a0e0f59259e4661322d0cf9a46d -size 13819 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png deleted file mode 100644 index 443230a159..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3e3765e543cce879b97a35b8c3e1cdef4b6c9ce4b1739b9d9a544d6a56118399 -size 11414 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png deleted file mode 100644 index 9ad249a00f..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:51b283fb8e976c8717abeef61dcf294f3efd52b7291fdf023c09759e6df6513d -size 11337 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png deleted file mode 100644 index ceafbe6d6a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c440b9628b8105b73f5f576c1b99269a74a89ab07f56080ad53cc888aca24da3 -size 18111 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png deleted file mode 100644 index 3cf9c17cda..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpaceItemView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9ddecc15d26dbdd648ec7abbff51718da03326ebf32a2d675563110c096a198b -size 18141 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png index bd804824f9..f36c5aa998 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dff393d500d855842d84d311b3bb5cb60bab6fc2e8e9efe3b593d675218d5024 -size 123012 +oid sha256:4053802814238aa74a5c606a4b37e0afcb923282c3e7cece737b0ec4f6828351 +size 106332 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png index 6159775dd0..b77735afed 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9993c1937c780b4b7e3a47bfb53fa5735ac7bc20f29559281384ca32eb7c13ae -size 120701 +oid sha256:d02259f3163c49b5c39d38d7a84af50583f6a0ce2a66e8ad013ac18278de65c0 +size 103621 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png new file mode 100644 index 0000000000..59cd1f1de1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e69f830b7f15de531fc46efc02964fb641a4e3754b5108fb8020e97bbfb4b56 +size 15698 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png new file mode 100644 index 0000000000..393434d50b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5fba00f44ab63c07978ec0143f2f1fefc8fd1d78aed22a367e831c105b806c7 +size 17306 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png new file mode 100644 index 0000000000..2fa59b0ada --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c7b387db1b0637745dfc93559b9afa3c637e5a492bf19f6f1d0dbe802ed73bb +size 47156 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png new file mode 100644 index 0000000000..8cb2f6776c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9892ec0c06bfa6e0b3a95fd35efa97e44f2e1f6264a324d6fdd994847f1830d1 +size 45897 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png new file mode 100644 index 0000000000..a2c2d2043d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91ad7e392e21afe93606956e8e7eab7f0b075574e782ff15e873a71fc3ab16ab +size 15637 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png new file mode 100644 index 0000000000..67cdaf4a39 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d50be9f20ed0e75e337d790edb329996abf9e626591882088817301338fadb6 +size 17213 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png new file mode 100644 index 0000000000..6e55a5816e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d76f7b10d8c88f39ab36581be35dd2a5ca5e7c3a92c69b96d4f3ec87f2e93468 +size 46564 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png new file mode 100644 index 0000000000..298a35ce39 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl_SpaceView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:057ceb522ff354d0d7e8b73d59eb53c559f9c22a2727d24744dc6a34b9daaa55 +size 45034