Repository: element-hq/element-x-android (language: kotlin)

Issue:
[Task] Create a SpaceList screen.
This is the top-level screen shown in the Spaces tab.

Please see and discuss the details in the meta issue.



Discussion hints:
nan

Pull request: Space list
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Content

<!-- Describe shortly what has been changed -->
Allow navigation in the Space hierarchy, including ending up opening rooms.

Note: the branch `feature/fga/space_list2` is a new branch with @ganfra 's commits from the branch `feature/fga/space_list`, and cleanup commits and commits that add TU from me.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #5125

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

## Tests

<!-- Explain how you tested your development -->

- With an account with Spaces
- Enable Spaces FF
- Navigate to the space tab
- Observe that you can now click on any listed Spaces, in this case, it will open the Space screen with header and room / space list if the space has already been joined. Joining space is not part of this PR.
- Observe that you can now click on any listed room, in this case, it will open the timeline if the room has been joined, or the already existing screen for room preview.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR


Test command: ./gradlew :libraries:matrix:impl:testDebugUnitTest --tests "io.element.android.libraries.matrix.impl.spaces.SpaceRoomCacheTest" --tests "io.element.android.libraries.matrix.impl.fixtures.factories.SpaceRoom" --tests "io.element.android.libraries.matrix.impl.spaces.RoomSummaryListProcessorTest" --tests "io.element.android.libraries.matrix.impl.spaces.RustSpaceRoomListTest" :features:space:impl:testDebugUnitTest --tests "io.element.android.features.space.impl.SpacePresenterTest"

Fix patch:
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 2d08d5f6f5c..97fff8ee5c2 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<String>) {
                         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<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
+                createNode<RoomFlowNode>(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 12d9df78cfa..f2df6ee8c9f 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<RoomFlowNode.NavTarget>(
     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<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
             }
+            is NavTarget.Space -> {
+                val spaceCallback = plugins<SpaceEntryPoint.Callback>().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 57fefaf4132..f0ebe6c02d9 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 6d531504ab4..37727712fba 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 9c77ab816ae..b8e299c5e96 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>(RoomListState.ContextMenu.Hidden) }
         val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(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 29ceca14bfc..dea6defc0a1 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<HomeSpacesState> {
     @Composable
     override fun present(): HomeSpacesState {
-        val hideInvitesAvatar by remember {
-            client
-                .mediaPreviewService()
-                .mediaPreviewConfigFlow
-                .mapState { config -> config.hideInviteAvatar }
-        }.collectAsState()
-        val spaceRooms by client.spaceService.spaceRooms.collectAsState(emptyList())
+        val 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 6def46c7b35..96733991f9c 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<SpaceRoom>,
-    val seenSpaceInvites: ImmutableSet<SpaceId>,
+    val seenSpaceInvites: ImmutableSet<RoomId>,
     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 466f515b263..921c3408864 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<HomeSpacesState> {
@@ -18,12 +19,12 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
             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<HomeSpacesState> {
 internal fun aHomeSpacesState(
     space: CurrentSpace = CurrentSpace.Root,
     spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
-    seenSpaceInvites: Set<SpaceId> = emptySet(),
+    seenSpaceInvites: Set<RoomId> = emptySet(),
     hideInvitesAvatar: Boolean = false,
     eventSink: (HomeSpacesEvents) -> Unit = {},
 ) = HomeSpacesState(
@@ -46,8 +47,8 @@ internal fun aHomeSpacesState(
 
 fun aListOfSpaceRooms(): List<SpaceRoom> {
     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 be6639e94b8..8b07b9f5260 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 29c18c78a0e..474e08293a2 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<SpaceRoom> {
     override val values: Sequence<SpaceRoom> = 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<MatrixUser> = emptyList(),
-    joinRule: JoinRule? = null,
-    numJoinedMembers: Int = 0,
-    spaceId: SpaceId = SpaceId("!spaceId:example.com"),
-    roomType: RoomType = RoomType.Space,
-    state: CurrentUserMembership? = null,
-    topic: String? = null,
-    worldReadable: Boolean = false,
-) = SpaceRoom(
-    name = name,
-    avatarUrl = avatarUrl,
-    canonicalAlias = canonicalAlias,
-    childrenCount = childrenCount,
-    guestCanJoin = guestCanJoin,
-    heroes = heroes,
-    joinRule = joinRule,
-    numJoinedMembers = numJoinedMembers,
-    spaceId = spaceId,
-    roomType = roomType,
-    state = state,
-    topic = topic,
-    worldReadable = worldReadable
-)
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 6c609e3810f..682970ffe7d 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<Set<SpaceId>> {
-    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 04e5b904e64..5fe12a42d9f 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<AsyncAction<Unit>> = 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<ContentState>(
diff --git a/features/space/api/build.gradle.kts b/features/space/api/build.gradle.kts
new file mode 100644
index 00000000000..dd19efefece
--- /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 00000000000..88cbbad5ddd
--- /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 00000000000..b41f8ebc939
--- /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 00000000000..1ef8275b275
--- /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<Plugin>()
+        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<SpaceNode>(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 00000000000..848dac3ebce
--- /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 00000000000..d37be4c76cf
--- /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<Plugin>,
+    presenterFactory: SpacePresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+    private val inputs = plugins.filterIsInstance<SpaceEntryPoint.Inputs>().single()
+    private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().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 00000000000..95597371a6a
--- /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<SpaceState> {
+    @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 00000000000..ad822283ca6
--- /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<SpaceRoom>,
+    val seenSpaceInvites: ImmutableSet<RoomId>,
+    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 00000000000..36c2ab62b02
--- /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<SpaceState> {
+    override val values: Sequence<SpaceState>
+        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<SpaceRoom> = emptyList(),
+    seenSpaceInvites: Set<RoomId> = 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<SpaceRoom> {
+    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 00000000000..f3bdf7379b2
--- /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/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 1fab64020d7..8d339ae7040 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 ce697089c7c..d4e1d57826b 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<MatrixUser>,
     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 00000000000..a591fc25371
--- /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<SpaceRoom?>
+
+    val spaceRoomsFlow: Flow<List<SpaceRoom>>
+    val paginationStatusFlow: StateFlow<PaginationStatus>
+    suspend fun paginate(): Result<Unit>
+}
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 58494502f7d..b4572ad0bb8 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<List<SpaceRoom>>
+    val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
     suspend fun joinedSpaces(): Result<List<SpaceRoom>>
+
+    fun spaceRoomList(id: RoomId): SpaceRoomList
 }
diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts
index a0fa346be28..757d7aa1fc0 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 00000000000..e3f2132a2fd
--- /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<InnerSpaceRoomList>()
+
+    override fun currentSpaceFlow(): Flow<SpaceRoom?> {
+        return spaceRoomCache.getSpaceRoomFlow(roomId)
+    }
+
+    override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
+    override val paginationStatusFlow: MutableStateFlow<SpaceRoomList.PaginationStatus> =
+        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<Unit> {
+        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 b059d476bcc..2a633675774 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<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
+    private val spaceRoomMapper = SpaceRoomMapper()
+    private val spaceRoomCache = SpaceRoomCache()
+    override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
+    private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
+        spaceRoomsFlow = spaceRoomsFlow,
+        mapper = spaceRoomMapper,
+        spaceRoomCache = spaceRoomCache
+    )
 
     override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
         runCatchingExceptions {
             innerSpaceService.joinedSpaces()
                 .map {
-                    it.let(mapper::map)
+                    it.let(spaceRoomMapper::map)
                 }
         }
     }
 
-    // override suspend fun spaceRoomList(spaceId: SpaceId): Result<List<SpaceRoom>> = 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<SpaceListUpdate>) {
-        mutex.withLock {
-            val current = if (spaceRooms.replayCache.isNotEmpty()) {
-                spaceRooms.first().toMutableList()
-            } else {
-                mutableListOf()
-            }
-            spaceListUpdates.forEach { update ->
-                current.applyUpdate(update)
-            }
-            spaceRooms.emit(current)
-        }
-    }
-
-    private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {
-        when (update) {
-            is SpaceListUpdate.Append -> {
-                val newSpaces = update.values.map(mapper::map)
-                addAll(newSpaces)
-            }
-            SpaceListUpdate.Clear -> clear()
-            is SpaceListUpdate.Insert -> {
-                val newSpace = mapper.map(update.value)
-                add(update.index.toInt(), newSpace)
-            }
-            SpaceListUpdate.PopBack -> {
-                removeAt(lastIndex)
-            }
-            SpaceListUpdate.PopFront -> {
-                removeAt(0)
-            }
-            is SpaceListUpdate.PushBack -> {
-                val newSpace = mapper.map(update.value)
-                add(newSpace)
-            }
-            is SpaceListUpdate.PushFront -> {
-                val newSpace = mapper.map(update.value)
-                add(0, newSpace)
-            }
-            is SpaceListUpdate.Remove -> {
-                removeAt(update.index.toInt())
-            }
-            is SpaceListUpdate.Reset -> {
-                clear()
-                val newSpaces = update.values.map(mapper::map)
-                addAll(newSpaces)
-            }
-            is SpaceListUpdate.Set -> {
-                val newSpace = mapper.map(update.value)
-                this[update.index.toInt()] = newSpace
-            }
-            is SpaceListUpdate.Truncate -> {
-                subList(update.length.toInt(), size).clear()
-            }
-        }
-    }
 }
 
-internal fun SpaceServiceInterface.spaceDiffFlow(): Flow<List<SpaceListUpdate>> =
+internal fun SpaceServiceInterface.spaceListUpdate(): Flow<List<SpaceListUpdate>> =
     callbackFlow {
         val listener = object : SpaceServiceJoinedSpacesListener {
             override fun onUpdate(roomUpdates: List<SpaceListUpdate>) {
                 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 00000000000..f1193661eaf
--- /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<List<SpaceRoom>>,
+    private val mapper: SpaceRoomMapper,
+    private val spaceRoomCache: SpaceRoomCache,
+) {
+    private val mutex = Mutex()
+
+    suspend fun postUpdates(updates: List<SpaceListUpdate>) {
+        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<SpaceRoom>.() -> 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<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {
+        when (update) {
+            is SpaceListUpdate.Append -> {
+                val newSpaces = update.values.map(mapper::map)
+                addAll(newSpaces)
+            }
+            SpaceListUpdate.Clear -> clear()
+            is SpaceListUpdate.Insert -> {
+                val newSpace = mapper.map(update.value)
+                add(update.index.toInt(), newSpace)
+            }
+            SpaceListUpdate.PopBack -> {
+                removeAt(lastIndex)
+            }
+            SpaceListUpdate.PopFront -> {
+                removeAt(0)
+            }
+            is SpaceListUpdate.PushBack -> {
+                val newSpace = mapper.map(update.value)
+                add(newSpace)
+            }
+            is SpaceListUpdate.PushFront -> {
+                val newSpace = mapper.map(update.value)
+                add(0, newSpace)
+            }
+            is SpaceListUpdate.Remove -> {
+                removeAt(update.index.toInt())
+            }
+            is SpaceListUpdate.Reset -> {
+                clear()
+                val newSpaces = update.values.map(mapper::map)
+                addAll(newSpaces)
+            }
+            is SpaceListUpdate.Set -> {
+                val newSpace = mapper.map(update.value)
+                this[update.index.toInt()] = newSpace
+            }
+            is SpaceListUpdate.Truncate -> {
+                subList(update.length.toInt(), size).clear()
+            }
+        }
+    }
+}
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 00000000000..8b7d1f5e1b5
--- /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<Map<RoomId, SpaceRoom?>>(emptyMap())
+    fun getSpaceRoomFlow(roomId: RoomId): Flow<SpaceRoom?> {
+        return inMemoryCache.mapState { it[roomId] }
+    }
+
+    fun update(spaceRooms: List<SpaceRoom>) {
+        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 00000000000..3d1a002e4d5
--- /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<SpaceRoomListPaginationState> = 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<List<SpaceListUpdate>> =
+    callbackFlow {
+        val listener = object : SpaceRoomListEntriesListener {
+            override fun onUpdate(rooms: List<SpaceListUpdate>) {
+                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 5622ee2c8c1..c217f941c73 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/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 461c550d8e7..be540d486dd 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 afe03b68772..e4b056fdea7 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 00000000000..0eb2b37ea4f
--- /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<Boolean> {
+    return remember {
+        mediaPreviewService()
+            .mediaPreviewConfigFlow
+            .mapState { config -> config.hideInviteAvatar }
+    }.collectAsState()
+}


Test patch:
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 00000000000..0bcd1303ae9
--- /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<Unit>> {
+            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<Unit>> {
+            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/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 00000000000..c699ef80973
--- /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<RoomHero> = 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 00000000000..171afd49fcf
--- /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<SpaceRoom> = { 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<SpaceRoom> {
+        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<SpaceListUpdate>) {
+        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 00000000000..47c3a19a6ba
--- /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<List<SpaceRoom>>(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<List<SpaceRoom>>(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<List<SpaceRoom>> = 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 00000000000..6ef32596574
--- /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<Unit> { }
+        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 00000000000..0679ce1a331
--- /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 00000000000..cd5bf7eb538
--- /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<SpaceRoom> = emptyList(),
+    initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading,
+    private val paginateResult: () -> Result<Unit> = { lambdaError() },
+) : SpaceRoomList {
+    private val currentSpaceMutableStateFlow: MutableStateFlow<SpaceRoom?> = MutableStateFlow(initialSpaceFlowValue)
+    override fun currentSpaceFlow(): Flow<SpaceRoom?> = currentSpaceMutableStateFlow.asStateFlow()
+
+    fun emitCurrentSpace(value: SpaceRoom?) {
+        currentSpaceMutableStateFlow.value = value
+    }
+
+    private val _spaceRoomsFlow: MutableStateFlow<List<SpaceRoom>> = MutableStateFlow(initialSpaceRoomsValue)
+    override val spaceRoomsFlow: Flow<List<SpaceRoom>> = _spaceRoomsFlow.asStateFlow()
+
+    fun emitSpaceRooms(value: List<SpaceRoom>) {
+        _spaceRoomsFlow.value = value
+    }
+
+    private val _paginationStatusFlow: MutableStateFlow<SpaceRoomList.PaginationStatus> = MutableStateFlow(initialSpaceRoomList)
+    override val paginationStatusFlow: StateFlow<SpaceRoomList.PaginationStatus> = _paginationStatusFlow.asStateFlow()
+
+    fun emitPaginationStatus(value: SpaceRoomList.PaginationStatus) {
+        _paginationStatusFlow.value = value
+    }
+
+    override suspend fun paginate(): Result<Unit> = 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 54005b122b8..43cc8ae8d29 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<List<SpaceRoom>> = { lambdaError() }
+    private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
+    private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
 ) : SpaceService {
-    private val _spaceRooms = MutableSharedFlow<List<SpaceRoom>>()
-    override val spaceRooms: SharedFlow<List<SpaceRoom>>
-        get() = _spaceRooms.asSharedFlow()
+    private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
+    override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
+        get() = _spaceRoomsFlow.asSharedFlow()
 
     suspend fun emitSpaceRoomList(value: List<SpaceRoom>) {
-        _spaceRooms.emit(value)
+        _spaceRoomsFlow.emit(value)
     }
 
     override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = simulateLongTask {
         return joinedSpacesResult()
     }
+
+    override fun spaceRoomList(id: RoomId): SpaceRoomList {
+        return spaceRoomListResult(id)
+    }
 }
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 00000000000..3acea6255b6
--- /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<MatrixUser> = 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 4dc4712e677..00000000000
--- 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 dc4f56f39b7..00000000000
--- 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 403a96cbad0..00000000000
--- 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 f12b559ded0..00000000000
--- 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 833415fba7a..00000000000
--- 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 175a0f01518..00000000000
--- 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 443230a1595..00000000000
--- 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 9ad249a00f1..00000000000
--- 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 ceafbe6d6a9..00000000000
--- 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 3cf9c17cda2..00000000000
--- 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 bd804824f93..f36c5aa998e 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 6159775dd07..b77735afed0 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 00000000000..59cd1f1de1b
--- /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 00000000000..393434d50b9
--- /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 00000000000..2fa59b0adaf
--- /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 00000000000..8cb2f6776ce
--- /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 00000000000..a2c2d2043d4
--- /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 00000000000..67cdaf4a391
--- /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 00000000000..6e55a5816ee
--- /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 00000000000..298a35ce39b
--- /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