Feature/fga/room directory

<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Type of change

- [x] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :

## Content
Room directory search should disabled for release build.
Can be enabled in preferences in debug/nightly.

The feature is accessible from room list search.

More complex navigation will be handled by permalink feature (opening room preview before joining, knocking...).

<!-- Describe shortly what has been changed -->

## Motivation and context
Closes #2578
<!-- Provide link to the corresponding issue if applicable or explain the context -->

## 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 -->

- Step 1
- Step 2
- Step ...

## Tested devices

- [ ] Physical
- [ ] 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 23
- [ ] 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 includes a new file under ./changelog.d. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR

[Task] Join a public room from the room search directory
From element-x-ios created by [Velin92](https://github.com/Velin92): element-hq/element-x-ios#2590

If a room is joinable it should show a Join button (even if the whole cell should be tappable).
This will show a loader, the search view will be dismissed, and then the user will land on the room that they have joined.

diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 8d81632f83e..fe63bb677dc 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="KotlinJpsPluginSettings">
-    <option name="version" value="1.9.22" />
+    <option name="version" value="1.9.23" />
   </component>
 </project>
\ No newline at end of file
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 e4290d5bdf4..a9dd485285d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -53,6 +53,7 @@ import io.element.android.features.lockscreen.api.LockScreenService
 import io.element.android.features.networkmonitor.api.NetworkMonitor
 import io.element.android.features.networkmonitor.api.NetworkStatus
 import io.element.android.features.preferences.api.PreferencesEntryPoint
+import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
 import io.element.android.features.roomlist.api.RoomListEntryPoint
 import io.element.android.features.securebackup.api.SecureBackupEntryPoint
 import io.element.android.features.verifysession.api.VerifySessionEntryPoint
@@ -97,6 +98,7 @@ class LoggedInFlowNode @AssistedInject constructor(
     private val ftueState: FtueState,
     private val lockScreenEntryPoint: LockScreenEntryPoint,
     private val lockScreenStateService: LockScreenService,
+    private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
     private val matrixClient: MatrixClient,
     snackbarDispatcher: SnackbarDispatcher,
 ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
@@ -225,6 +227,9 @@ class LoggedInFlowNode @AssistedInject constructor(

         @Parcelize
         data object Ftue : NavTarget
+
+        @Parcelize
+        data object RoomDirectorySearch : NavTarget
     }

     override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -270,6 +275,10 @@ class LoggedInFlowNode @AssistedInject constructor(
                     override fun onReportBugClicked() {
                         plugins<Callback>().forEach { it.onOpenBugReport() }
                     }
+
+                    override fun onRoomDirectorySearchClicked() {
+                        backstack.push(NavTarget.RoomDirectorySearch)
+                    }
                 }
                 roomListEntryPoint
                     .nodeBuilder(this, buildContext)
@@ -377,6 +386,15 @@ class LoggedInFlowNode @AssistedInject constructor(
                     })
                     .build()
             }
+            NavTarget.RoomDirectorySearch -> {
+                roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
+                    .callback(object : RoomDirectoryEntryPoint.Callback {
+                        override fun onOpenRoom(roomId: RoomId) {
+                            coroutineScope.launch { attachRoom(roomId) }
+                        }
+                    })
+                    .build()
+            }
         }
     }

diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index cde723257fb..b6bf76c4403 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -29,7 +29,11 @@
   <string name="screen_room_change_role_confirm_demote_self_action">"Demote"</string>
   <string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
   <string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
+  <string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
   <string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
+  <string name="screen_room_change_role_section_administrators">"Admins"</string>
+  <string name="screen_room_change_role_section_moderators">"Moderators"</string>
+  <string name="screen_room_change_role_section_users">"Members"</string>
   <string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
   <string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
   <string name="screen_room_details_add_topic_title">"Add topic"</string>
diff --git a/features/roomdirectory/api/build.gradle.kts b/features/roomdirectory/api/build.gradle.kts
new file mode 100644
index 00000000000..04a813bd0fb
--- /dev/null
+++ b/features/roomdirectory/api/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+    id("io.element.android-library")
+}
+
+android {
+    namespace = "io.element.android.features.roomdirectory.api"
+}
+
+dependencies {
+    implementation(projects.libraries.architecture)
+    implementation(projects.libraries.matrix.api)
+    implementation(projects.libraries.designsystem)
+}
diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
new file mode 100644
index 00000000000..5b945b2a7d6
--- /dev/null
+++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.api
+
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class RoomDescription(
+    val roomId: RoomId,
+    val name: String,
+    val description: String,
+    val avatarData: AvatarData,
+    val canBeJoined: Boolean,
+)
diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt
new file mode 100644
index 00000000000..5a693a4a834
--- /dev/null
+++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.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 RoomDirectoryEntryPoint : FeatureEntryPoint {
+    fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+    interface NodeBuilder {
+        fun callback(callback: Callback): NodeBuilder
+        fun build(): Node
+    }
+
+    interface Callback : Plugin {
+        fun onOpenRoom(roomId: RoomId)
+    }
+}
diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts
new file mode 100644
index 00000000000..85bb195da30
--- /dev/null
+++ b/features/roomdirectory/impl/build.gradle.kts
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+    id("io.element.android-compose-library")
+    alias(libs.plugins.anvil)
+    alias(libs.plugins.ksp)
+    id("kotlin-parcelize")
+}
+
+android {
+    namespace = "io.element.android.features.roomdirectory.impl"
+    testOptions {
+        unitTests {
+            isIncludeAndroidResources = true
+        }
+    }
+}
+
+anvil {
+    generateDaggerFactories.set(true)
+}
+
+dependencies {
+    implementation(projects.anvilannotations)
+    anvil(projects.anvilcodegen)
+    api(projects.features.roomdirectory.api)
+    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.testtags)
+
+    testImplementation(libs.test.junit)
+    testImplementation(libs.androidx.compose.ui.test.junit)
+    testImplementation(libs.test.robolectric)
+    testImplementation(libs.coroutines.test)
+    testImplementation(libs.molecule.runtime)
+    testImplementation(libs.test.truth)
+    testImplementation(libs.test.turbine)
+    testImplementation(projects.libraries.matrix.test)
+    testImplementation(projects.tests.testutils)
+
+    ksp(libs.showkase.processor)
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt
new file mode 100644
index 00000000000..c15a748a9e5
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
+import io.element.android.features.roomdirectory.impl.root.RoomDirectoryNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultRoomDirectoryEntryPoint @Inject constructor() : RoomDirectoryEntryPoint {
+    override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDirectoryEntryPoint.NodeBuilder {
+        val plugins = ArrayList<Plugin>()
+
+        return object : RoomDirectoryEntryPoint.NodeBuilder {
+            override fun callback(callback: RoomDirectoryEntryPoint.Callback): RoomDirectoryEntryPoint.NodeBuilder {
+                plugins += callback
+                return this
+            }
+
+            override fun build(): Node {
+                return parentNode.createNode<RoomDirectoryNode>(buildContext, plugins)
+            }
+        }
+    }
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt
new file mode 100644
index 00000000000..37e0ffb3c6f
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+sealed interface RoomDirectoryEvents {
+    data class JoinRoom(val roomId: RoomId) : RoomDirectoryEvents
+    data class Search(val query: String) : RoomDirectoryEvents
+    data object LoadMore : RoomDirectoryEvents
+    data object JoinRoomDismissError : RoomDirectoryEvents
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt
new file mode 100644
index 00000000000..dc3581589e0
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+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 com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@ContributesNode(SessionScope::class)
+class RoomDirectoryNode @AssistedInject constructor(
+    @Assisted buildContext: BuildContext,
+    @Assisted plugins: List<Plugin>,
+    private val presenter: RoomDirectoryPresenter,
+) : Node(buildContext, plugins = plugins) {
+    private fun onRoomJoined(roomId: RoomId) {
+        plugins<RoomDirectoryEntryPoint.Callback>().forEach {
+            it.onOpenRoom(roomId)
+        }
+    }
+
+    @Composable
+    override fun View(modifier: Modifier) {
+        val state = presenter.present()
+        RoomDirectoryView(
+            state = state,
+            onRoomJoined = ::onRoomJoined,
+            onBackPressed = ::navigateUp,
+            modifier = modifier
+        )
+    }
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
new file mode 100644
index 00000000000..5d4cef55cbf
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
+import io.element.android.features.roomdirectory.impl.root.model.RoomDirectoryListState
+import io.element.android.features.roomdirectory.impl.root.model.toFeatureModel
+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.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class RoomDirectoryPresenter @Inject constructor(
+    private val dispatchers: CoroutineDispatchers,
+    private val joinRoom: JoinRoom,
+    private val roomDirectoryService: RoomDirectoryService,
+) : Presenter<RoomDirectoryState> {
+    @Composable
+    override fun present(): RoomDirectoryState {
+        var loadingMore by remember {
+            mutableStateOf(false)
+        }
+        var searchQuery by rememberSaveable {
+            mutableStateOf<String?>(null)
+        }
+        val coroutineScope = rememberCoroutineScope()
+        val roomDirectoryList = remember {
+            roomDirectoryService.createRoomDirectoryList(coroutineScope)
+        }
+        val listState by roomDirectoryList.collectState()
+        val joinRoomAction: MutableState<AsyncAction<RoomId>> = remember {
+            mutableStateOf(AsyncAction.Uninitialized)
+        }
+        LaunchedEffect(searchQuery) {
+            if (searchQuery == null) return@LaunchedEffect
+            // cancel load more right away
+            loadingMore = false
+            // debounce search query
+            delay(300)
+            roomDirectoryList.filter(searchQuery, 20)
+        }
+        LaunchedEffect(loadingMore) {
+            if (loadingMore) {
+                roomDirectoryList.loadMore()
+                loadingMore = false
+            }
+        }
+        fun handleEvents(event: RoomDirectoryEvents) {
+            when (event) {
+                RoomDirectoryEvents.LoadMore -> {
+                    loadingMore = true
+                }
+                is RoomDirectoryEvents.Search -> {
+                    searchQuery = event.query
+                }
+                is RoomDirectoryEvents.JoinRoom -> {
+                    coroutineScope.joinRoom(joinRoomAction, event.roomId)
+                }
+                RoomDirectoryEvents.JoinRoomDismissError -> {
+                    joinRoomAction.value = AsyncAction.Uninitialized
+                }
+            }
+        }
+
+        return RoomDirectoryState(
+            query = searchQuery.orEmpty(),
+            roomDescriptions = listState.items,
+            displayLoadMoreIndicator = listState.hasMoreToLoad,
+            joinRoomAction = joinRoomAction.value,
+            eventSink = ::handleEvents
+        )
+    }
+
+    private fun CoroutineScope.joinRoom(state: MutableState<AsyncAction<RoomId>>, roomId: RoomId) = launch {
+        state.runUpdatingState {
+            joinRoom(roomId)
+        }
+    }
+
+    @Composable
+    private fun RoomDirectoryList.collectState() = remember {
+        state.map {
+            val items = it.items
+                .map { roomDescription -> roomDescription.toFeatureModel() }
+                .toImmutableList()
+            RoomDirectoryListState(items = items, hasMoreToLoad = it.hasMoreToLoad)
+        }.flowOn(dispatchers.computation)
+    }.collectAsState(RoomDirectoryListState.Default)
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt
new file mode 100644
index 00000000000..526139338d8
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+
+data class RoomDirectoryState(
+    val query: String,
+    val roomDescriptions: ImmutableList<RoomDescription>,
+    val displayLoadMoreIndicator: Boolean,
+    val joinRoomAction: AsyncAction<RoomId>,
+    val eventSink: (RoomDirectoryEvents) -> Unit
+) {
+    val displayEmptyState = roomDescriptions.isEmpty() && !displayLoadMoreIndicator
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
new file mode 100644
index 00000000000..efb6624260b
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+open class RoomDirectoryStateProvider : PreviewParameterProvider<RoomDirectoryState> {
+    override val values: Sequence<RoomDirectoryState>
+        get() = sequenceOf(
+            aRoomDirectoryState(),
+            aRoomDirectoryState(
+                query = "Element",
+                roomDescriptions = aRoomDescriptionList(),
+            ),
+            aRoomDirectoryState(
+                query = "Element",
+                roomDescriptions = aRoomDescriptionList(),
+                displayLoadMoreIndicator = true,
+            ),
+            aRoomDirectoryState(
+                query = "Element",
+                roomDescriptions = aRoomDescriptionList(),
+                joinRoomAction = AsyncAction.Loading,
+            ),
+            aRoomDirectoryState(
+                query = "Element",
+                roomDescriptions = aRoomDescriptionList(),
+                joinRoomAction = AsyncAction.Failure(Exception("Failed to join room")),
+            ),
+        )
+}
+
+fun aRoomDirectoryState(
+    query: String = "",
+    displayLoadMoreIndicator: Boolean = false,
+    roomDescriptions: ImmutableList<RoomDescription> = persistentListOf(),
+    joinRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
+    eventSink: (RoomDirectoryEvents) -> Unit = {},
+) = RoomDirectoryState(
+    query = query,
+    roomDescriptions = roomDescriptions,
+    displayLoadMoreIndicator = displayLoadMoreIndicator,
+    joinRoomAction = joinRoomAction,
+    eventSink = eventSink,
+)
+
+fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
+    return persistentListOf(
+        RoomDescription(
+            roomId = RoomId("!exa:matrix.org"),
+            name = "Element X Android",
+            description = "Element X is a secure, private and decentralized messenger.",
+            avatarData = AvatarData(
+                id = "!exa:matrix.org",
+                name = "Element X Android",
+                url = null,
+                size = AvatarSize.RoomDirectoryItem
+            ),
+            canBeJoined = true,
+        ),
+        RoomDescription(
+            roomId = RoomId("!exi:matrix.org"),
+            name = "Element X iOS",
+            description = "Element X is a secure, private and decentralized messenger.",
+            avatarData = AvatarData(
+                id = "!exi:matrix.org",
+                name = "Element X iOS",
+                url = null,
+                size = AvatarSize.RoomDirectoryItem
+            ),
+            canBeJoined = false,
+        )
+    )
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
new file mode 100644
index 00000000000..f6188c32699
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
@@ -0,0 +1,321 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+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.features.roomdirectory.api.RoomDescription
+import io.element.android.features.roomdirectory.impl.R
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+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.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+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.TextField
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun RoomDirectoryView(
+    state: RoomDirectoryState,
+    onRoomJoined: (RoomId) -> Unit,
+    onBackPressed: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    fun joinRoom(roomId: RoomId) {
+        state.eventSink(RoomDirectoryEvents.JoinRoom(roomId))
+    }
+
+    Scaffold(
+        modifier = modifier,
+        topBar = {
+            RoomDirectoryTopBar(onBackPressed = onBackPressed)
+        },
+        content = { padding ->
+            RoomDirectoryContent(
+                state = state,
+                onResultClicked = ::joinRoom,
+                modifier = Modifier
+                    .padding(padding)
+                    .consumeWindowInsets(padding)
+            )
+        }
+    )
+    AsyncActionView(
+        async = state.joinRoomAction,
+        onSuccess = onRoomJoined,
+        onErrorDismiss = {
+            state.eventSink(RoomDirectoryEvents.JoinRoomDismissError)
+        },
+        errorMessage = {
+            stringResource(id = CommonStrings.error_unknown)
+        }
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RoomDirectoryTopBar(
+    onBackPressed: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    TopAppBar(
+        modifier = modifier,
+        navigationIcon = {
+            BackButton(onClick = onBackPressed)
+        },
+        title = {
+            Text(
+                text = stringResource(id = R.string.screen_room_directory_search_title),
+                style = ElementTheme.typography.aliasScreenTitle,
+            )
+        }
+    )
+}
+
+@Composable
+private fun RoomDirectoryContent(
+    state: RoomDirectoryState,
+    onResultClicked: (RoomId) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Column(modifier = modifier) {
+        SearchTextField(
+            query = state.query,
+            onQueryChange = { state.eventSink(RoomDirectoryEvents.Search(it)) },
+            placeholder = stringResource(id = CommonStrings.action_search),
+            modifier = Modifier.fillMaxWidth(),
+        )
+        RoomDirectoryRoomList(
+            roomDescriptions = state.roomDescriptions,
+            displayLoadMoreIndicator = state.displayLoadMoreIndicator,
+            displayEmptyState = state.displayEmptyState,
+            onResultClicked = onResultClicked,
+            onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) },
+        )
+    }
+}
+
+@Composable
+private fun RoomDirectoryRoomList(
+    roomDescriptions: ImmutableList<RoomDescription>,
+    displayLoadMoreIndicator: Boolean,
+    displayEmptyState: Boolean,
+    onResultClicked: (RoomId) -> Unit,
+    onReachedLoadMore: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(modifier = modifier) {
+        items(roomDescriptions) { roomDescription ->
+            RoomDirectoryRoomRow(
+                roomDescription = roomDescription,
+                onClick = onResultClicked,
+            )
+        }
+        if (displayEmptyState) {
+            item {
+                Text(
+                    text = stringResource(id = CommonStrings.common_no_results),
+                    style = ElementTheme.typography.fontBodyLgRegular,
+                    color = ElementTheme.colors.textPlaceholder,
+                    modifier = Modifier.padding(16.dp)
+                )
+            }
+        }
+        if (displayLoadMoreIndicator) {
+            item {
+                LoadMoreIndicator(modifier = Modifier.fillMaxWidth())
+                LaunchedEffect(onReachedLoadMore) {
+                    onReachedLoadMore()
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun LoadMoreIndicator(modifier: Modifier = Modifier) {
+    Box(
+        modifier
+            .fillMaxWidth()
+            .wrapContentHeight()
+            .padding(24.dp),
+        contentAlignment = Alignment.Center,
+    ) {
+        CircularProgressIndicator(
+            strokeWidth = 2.dp,
+        )
+    }
+}
+
+@Composable
+private fun SearchTextField(
+    query: String,
+    onQueryChange: (String) -> Unit,
+    placeholder: String,
+    modifier: Modifier = Modifier,
+    colors: TextFieldColors = TextFieldDefaults.colors(
+        focusedContainerColor = Color.Transparent,
+        unfocusedContainerColor = Color.Transparent,
+        unfocusedPlaceholderColor = ElementTheme.colors.textPlaceholder,
+        focusedPlaceholderColor = ElementTheme.colors.textPlaceholder,
+        focusedTextColor = ElementTheme.colors.textPrimary,
+        unfocusedTextColor = ElementTheme.colors.textPrimary,
+        focusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,
+        unfocusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,
+    ),
+) {
+    val focusManager = LocalFocusManager.current
+    TextField(
+        modifier = modifier.testTag(TestTags.searchTextField.value),
+        textStyle = ElementTheme.typography.fontBodyLgRegular,
+        singleLine = true,
+        value = query,
+        onValueChange = onQueryChange,
+        keyboardActions = KeyboardActions(
+            onSearch = {
+                focusManager.clearFocus()
+            }
+        ),
+        colors = colors,
+        placeholder = { Text(placeholder) },
+        trailingIcon = {
+            if (query.isNotEmpty()) {
+                IconButton(
+                    onClick = {
+                        onQueryChange("")
+                    }
+                ) {
+                    Icon(
+                        imageVector = CompoundIcons.Close(),
+                        contentDescription = stringResource(CommonStrings.action_clear),
+                    )
+                }
+            } else {
+                Icon(
+                    imageVector = CompoundIcons.Search(),
+                    contentDescription = stringResource(CommonStrings.action_search),
+                )
+            }
+        },
+    )
+}
+
+@Composable
+private fun RoomDirectoryRoomRow(
+    roomDescription: RoomDescription,
+    onClick: (RoomId) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable(enabled = roomDescription.canBeJoined) {
+                onClick(roomDescription.roomId)
+            }
+            .padding(
+                top = 12.dp,
+                bottom = 12.dp,
+                start = 16.dp,
+            )
+            .height(IntrinsicSize.Min),
+    ) {
+        Avatar(
+            avatarData = roomDescription.avatarData,
+            modifier = Modifier.align(Alignment.CenterVertically)
+        )
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = 16.dp)
+        ) {
+            Text(
+                text = roomDescription.name,
+                maxLines = 1,
+                style = ElementTheme.typography.fontBodyLgRegular,
+                color = ElementTheme.colors.textPrimary,
+                overflow = TextOverflow.Ellipsis,
+            )
+            Text(
+                text = roomDescription.description,
+                maxLines = 1,
+                style = ElementTheme.typography.fontBodyMdRegular,
+                color = ElementTheme.colors.textSecondary,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
+        if (roomDescription.canBeJoined) {
+            Text(
+                text = stringResource(id = CommonStrings.action_join),
+                color = ElementTheme.colors.textSuccessPrimary,
+                modifier = Modifier
+                    .align(Alignment.CenterVertically)
+                    .padding(start = 4.dp, end = 12.dp)
+            )
+        } else {
+            Spacer(modifier = Modifier.width(24.dp))
+        }
+    }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview {
+    RoomDirectoryView(
+        state = state,
+        onRoomJoined = {},
+        onBackPressed = {},
+    )
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
new file mode 100644
index 00000000000..983d2a1dd23
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root.di
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import javax.inject.Inject
+
+interface JoinRoom {
+    suspend operator fun invoke(roomId: RoomId): Result<RoomId>
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultJoinRoom @Inject constructor(private val client: MatrixClient) : JoinRoom {
+    override suspend fun invoke(roomId: RoomId) = client.joinRoom(roomId)
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt
new file mode 100644
index 00000000000..be36eb5053c
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root.model
+
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription as MatrixRoomDescription
+
+fun MatrixRoomDescription.toFeatureModel(): RoomDescription {
+    fun name(): String {
+        return name ?: alias ?: roomId.value
+    }
+
+    fun description(): String {
+        val topic = topic
+        val alias = alias
+        val name = name
+        return when {
+            topic != null -> topic
+            name != null && alias != null -> alias
+            name == null && alias == null -> ""
+            else -> roomId.value
+        }
+    }
+
+    return RoomDescription(
+        roomId = roomId,
+        name = name(),
+        description = description(),
+        avatarData = AvatarData(
+            id = roomId.value,
+            name = name,
+            url = avatarUrl,
+            size = AvatarSize.RoomDirectoryItem,
+        ),
+        canBeJoined = joinRule == MatrixRoomDescription.JoinRule.PUBLIC,
+    )
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt
new file mode 100644
index 00000000000..60f344f67bf
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root.model
+
+import io.element.android.features.roomdirectory.api.RoomDescription
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+internal data class RoomDirectoryListState(
+    val hasMoreToLoad: Boolean,
+    val items: ImmutableList<RoomDescription>,
+) {
+    companion object {
+        val Default = RoomDirectoryListState(
+            hasMoreToLoad = true,
+            items = persistentListOf()
+        )
+    }
+}
diff --git a/features/roomdirectory/impl/src/main/res/values/localazy.xml b/features/roomdirectory/impl/src/main/res/values/localazy.xml
new file mode 100644
index 00000000000..d3fb9d15aec
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+  <string name="screen_room_directory_search_loading_error">"Failed loading"</string>
+  <string name="screen_room_directory_search_title">"Room directory"</string>
+</resources>
diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
index 0404ce18fc6..c3b2ff36a20 100644
--- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
+++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
@@ -38,5 +38,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
         fun onInvitesClicked()
         fun onRoomSettingsClicked(roomId: RoomId)
         fun onReportBugClicked()
+        fun onRoomDirectorySearchClicked()
     }
 }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
index 5dec53e158f..a9740bcfcba 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
@@ -91,6 +91,10 @@ class RoomListNode @AssistedInject constructor(
         }
     }

+    private fun onRoomDirectorySearchClicked() {
+        plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomDirectorySearchClicked() }
+    }
+
     @Composable
     override fun View(modifier: Modifier) {
         val state = presenter.present()
@@ -105,6 +109,7 @@ class RoomListNode @AssistedInject constructor(
             onInvitesClicked = this::onInvitesClicked,
             onRoomSettingsClicked = this::onRoomSettingsClicked,
             onMenuActionClicked = { onMenuActionClicked(activity, it) },
+            onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked,
             modifier = modifier,
         )
     }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
index 23a105f1439..e0a3fc52012 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
@@ -59,6 +59,7 @@ fun RoomListView(
     onInvitesClicked: () -> Unit,
     onRoomSettingsClicked: (roomId: RoomId) -> Unit,
     onMenuActionClicked: (RoomListMenuAction) -> Unit,
+    onRoomDirectorySearchClicked: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
     ConnectivityIndicatorContainer(
@@ -99,6 +100,7 @@ fun RoomListView(
                 state = state.searchState,
                 onRoomClicked = onRoomClicked,
                 onRoomLongClicked = { onRoomLongClicked(it) },
+                onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
                 modifier = Modifier
                     .statusBarsPadding()
                     .padding(top = topPadding)
@@ -197,5 +199,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
         onInvitesClicked = {},
         onRoomSettingsClicked = {},
         onMenuActionClicked = {},
+        onRoomDirectorySearchClicked = {},
     )
 }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
index 78bcda07f10..4ae7f091fe2 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
@@ -21,21 +21,25 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
 import kotlinx.collections.immutable.persistentListOf
 import javax.inject.Inject

 class RoomListSearchPresenter @Inject constructor(
     private val dataSource: RoomListSearchDataSource,
+    private val featureFlagService: FeatureFlagService,
 ) : Presenter<RoomListSearchState> {
     @Composable
     override fun present(): RoomListSearchState {
-        var isSearchActive by rememberSaveable {
+        // Do not use rememberSaveable so that search is not active when the user navigates back to the screen
+        var isSearchActive by remember {
             mutableStateOf(false)
         }
-        var searchQuery by rememberSaveable {
+        var searchQuery by remember {
             mutableStateOf("")
         }

@@ -62,12 +66,14 @@ class RoomListSearchPresenter @Inject constructor(
             }
         }

+        val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
         val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf())

         return RoomListSearchState(
             isSearchActive = isSearchActive,
             query = searchQuery,
             results = searchResults,
+            isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
             eventSink = ::handleEvents
         )
     }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt
index c4b24dc7983..92e70ad039d 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt
@@ -23,5 +23,8 @@ data class RoomListSearchState(
     val isSearchActive: Boolean,
     val query: String,
     val results: ImmutableList<RoomListRoomSummary>,
+    val isRoomDirectorySearchEnabled: Boolean,
     val eventSink: (RoomListSearchEvents) -> Unit
-)
+) {
+    val displayRoomDirectorySearch = query.isEmpty() && isRoomDirectorySearchEnabled
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt
index ae722a4b049..c4dcbab1ec2 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt
@@ -26,6 +26,7 @@ class RoomListSearchStateProvider : PreviewParameterProvider<RoomListSearchState
     override val values: Sequence<RoomListSearchState>
         get() = sequenceOf(
             aRoomListSearchState(),
+            aRoomListSearchState(isRoomDirectorySearchEnabled = true),
             aRoomListSearchState(
                 isSearchActive = true,
                 query = "Test",
@@ -38,10 +39,12 @@ fun aRoomListSearchState(
     isSearchActive: Boolean = false,
     query: String = "",
     results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
+    isRoomDirectorySearchEnabled: Boolean = false,
     eventSink: (RoomListSearchEvents) -> Unit = { },
 ) = RoomListSearchState(
     isSearchActive = isSearchActive,
     query = query,
     results = results,
+    isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
     eventSink = eventSink,
 )
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
index eff6449449e..80657ed4fc5 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
@@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.tooling.preview.PreviewParameter
 import androidx.compose.ui.unit.dp
 import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.roomlist.impl.R
 import io.element.android.features.roomlist.impl.components.RoomSummaryRow
 import io.element.android.features.roomlist.impl.contentType
 import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@@ -50,8 +51,10 @@ import io.element.android.libraries.designsystem.components.button.BackButton
 import io.element.android.libraries.designsystem.modifiers.applyIf
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
 import io.element.android.libraries.designsystem.theme.components.Icon
 import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.IconSource
 import io.element.android.libraries.designsystem.theme.components.Scaffold
 import io.element.android.libraries.designsystem.theme.components.TextField
 import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -64,6 +67,7 @@ internal fun RoomListSearchView(
     state: RoomListSearchState,
     onRoomClicked: (RoomId) -> Unit,
     onRoomLongClicked: (RoomListRoomSummary) -> Unit,
+    onRoomDirectorySearchClicked: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
     BackHandler(enabled = state.isSearchActive) {
@@ -87,6 +91,7 @@ internal fun RoomListSearchView(
                     state = state,
                     onRoomClicked = onRoomClicked,
                     onRoomLongClicked = onRoomLongClicked,
+                    onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
                 )
             }
         }
@@ -99,6 +104,7 @@ private fun RoomListSearchContent(
     state: RoomListSearchState,
     onRoomClicked: (RoomId) -> Unit,
     onRoomLongClicked: (RoomListRoomSummary) -> Unit,
+    onRoomDirectorySearchClicked: () -> Unit,
 ) {
     val borderColor = MaterialTheme.colorScheme.tertiary
     val strokeWidth = 1.dp
@@ -169,6 +175,14 @@ private fun RoomListSearchContent(
                 .padding(padding)
                 .consumeWindowInsets(padding)
         ) {
+            if (state.displayRoomDirectorySearch) {
+                RoomDirectorySearchButton(
+                    modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(vertical = 24.dp, horizontal = 16.dp),
+                    onClick = onRoomDirectorySearchClicked
+                )
+            }
             LazyColumn(
                 modifier = Modifier.weight(1f),
             ) {
@@ -187,12 +201,26 @@ private fun RoomListSearchContent(
     }
 }

+@Composable
+private fun RoomDirectorySearchButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Button(
+        text = stringResource(id = R.string.screen_roomlist_room_directory_button_title),
+        leadingIcon = IconSource.Vector(CompoundIcons.ListBulleted()),
+        onClick = onClick,
+        modifier = modifier,
+    )
+}
+
 @PreviewsDayNight
 @Composable
 internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
     RoomListSearchContent(
         state = state,
         onRoomClicked = {},
-        onRoomLongClicked = {}
+        onRoomLongClicked = {},
+        onRoomDirectorySearchClicked = {},
     )
 }
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
index 0451cb58398..2dc6c8875ff 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
@@ -51,5 +51,7 @@ enum class AvatarSize(val dp: Dp) {

     NotificationsOptIn(32.dp),

-    CustomRoomNotificationSetting(36.dp)
+    CustomRoomNotificationSetting(36.dp),
+
+    RoomDirectoryItem(36.dp),
 }
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index a242d014d17..5642eefbd27 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -89,4 +89,11 @@ enum class FeatureFlags(
         defaultValue = true,
         isFinished = false,
     ),
+    RoomDirectorySearch(
+        key = "feature.roomdirectorysearch",
+        title = "Room directory search",
+        description = "Allow user to search for public rooms in their homeserver",
+        defaultValue = false,
+        isFinished = false,
+    )
 }
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
index 4e7032a3130..e442a1e9607 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
@@ -42,6 +42,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
                 FeatureFlags.MarkAsUnread -> true
                 FeatureFlags.RoomListFilters -> true
                 FeatureFlags.RoomModeration -> false
+                FeatureFlags.RoomDirectorySearch -> false
             }
         } else {
             false
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index 955c8d72fa6..e6c536ee72a 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
 import io.element.android.libraries.matrix.api.pusher.PushersService
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.sync.SyncService
 import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
@@ -58,12 +59,14 @@ interface MatrixClient : Closeable {
     suspend fun setDisplayName(displayName: String): Result<Unit>
     suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
     suspend fun removeAvatar(): Result<Unit>
+    suspend fun joinRoom(roomId: RoomId): Result<RoomId>
     fun syncService(): SyncService
     fun sessionVerificationService(): SessionVerificationService
     fun pushersService(): PushersService
     fun notificationService(): NotificationService
     fun notificationSettingsService(): NotificationSettingsService
     fun encryptionService(): EncryptionService
+    fun roomDirectoryService(): RoomDirectoryService
     suspend fun getCacheSize(): Long

     /**
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt
new file mode 100644
index 00000000000..78d6cb0c946
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.roomdirectory
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class RoomDescription(
+    val roomId: RoomId,
+    val name: String?,
+    val topic: String?,
+    val alias: String?,
+    val avatarUrl: String?,
+    val joinRule: JoinRule,
+    val isWorldReadable: Boolean,
+    val numberOfMembers: Long
+) {
+    enum class JoinRule {
+        PUBLIC,
+        KNOCK,
+        UNKNOWN
+    }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
new file mode 100644
index 00000000000..2311c5afeef
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.roomdirectory
+
+import kotlinx.coroutines.flow.Flow
+
+interface RoomDirectoryList {
+    suspend fun filter(filter: String?, batchSize: Int): Result<Unit>
+    suspend fun loadMore(): Result<Unit>
+    val state: Flow<State>
+
+    data class State(
+        val hasMoreToLoad: Boolean,
+        val items: List<RoomDescription>,
+    )
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt
new file mode 100644
index 00000000000..26df48be719
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.roomdirectory
+
+import kotlinx.coroutines.CoroutineScope
+
+interface RoomDirectoryService {
+    fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 31e439bbec9..481c094fd49 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
 import io.element.android.libraries.matrix.api.pusher.PushersService
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
 import io.element.android.libraries.matrix.api.sync.SyncService
@@ -53,6 +54,7 @@ import io.element.android.libraries.matrix.impl.room.MatrixRoomInfoMapper
 import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
 import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
 import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
+import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
 import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
 import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
 import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
@@ -71,6 +73,7 @@ import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toPersistentList
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -101,6 +104,8 @@ import org.matrix.rustcomponents.sdk.use
 import timber.log.Timber
 import java.io.File
 import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
 import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
 import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
 import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
@@ -150,6 +155,12 @@ class RustMatrixClient(
         sessionCoroutineScope = sessionCoroutineScope,
         dispatchers = dispatchers,
     )
+
+    private val roomDirectoryService = RustRoomDirectoryService(
+        client = client,
+        sessionDispatcher = sessionDispatcher,
+    )
+
     private val sessionDirectoryNameProvider = SessionDirectoryNameProvider()

     private val isLoggingOut = AtomicBoolean(false)
@@ -311,6 +322,22 @@ class RustMatrixClient(
         }
     }

+    /**
+     * Wait for the room to be available in the room list.
+     * @param roomId the room id to wait for
+     * @param timeout the timeout to wait for the room to be available
+     * @throws TimeoutCancellationException if the room is not available after the timeout
+     */
+    private suspend fun awaitRoom(roomId: RoomId, timeout: Duration) {
+        withTimeout(timeout) {
+            roomListService.allRooms.summaries
+                .filter { roomSummaries ->
+                    roomSummaries.map { it.identifier() }.contains(roomId.value)
+                }
+                .first()
+        }
+    }
+
     private suspend fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? {
         val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value)
         val fullRoom = cachedRoomListItem?.fullRoomWithTimeline(filter = eventFilters)
@@ -360,14 +387,11 @@ class RustMatrixClient(
                 powerLevelContentOverride = defaultRoomCreationPowerLevels,
             )
             val roomId = RoomId(client.createRoom(rustParams))
-
-            // Wait to receive the room back from the sync
-            withTimeout(30_000L) {
-                roomListService.allRooms.summaries
-                    .filter { roomSummaries ->
-                        roomSummaries.map { it.identifier() }.contains(roomId.value)
-                    }
-                    .first()
+            // Wait to receive the room back from the sync but do not returns failure if it fails.
+            try {
+                awaitRoom(roomId, 30.seconds)
+            } catch (e: Exception) {
+                Timber.e(e, "Timeout waiting for the room to be available in the room list")
             }
             roomId
         }
@@ -416,6 +440,18 @@ class RustMatrixClient(
             runCatching { client.removeAvatar() }
         }

+    override suspend fun joinRoom(roomId: RoomId): Result<RoomId> = withContext(sessionDispatcher) {
+        runCatching {
+            client.joinRoomById(roomId.value).destroy()
+            try {
+                awaitRoom(roomId, 10.seconds)
+            } catch (e: Exception) {
+                Timber.e(e, "Timeout waiting for the room to be available in the room list")
+            }
+            roomId
+        }
+    }
+
     override fun syncService(): SyncService = rustSyncService

     override fun sessionVerificationService(): SessionVerificationService = verificationService
@@ -428,6 +464,8 @@ class RustMatrixClient(

     override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService

+    override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
+
     override fun close() {
         sessionCoroutineScope.cancel()
         clientDelegateTaskHandle?.cancelAndDestroy()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
index 17ea8ee4446..bf5c4c601f1 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
 import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
 import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
 import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.verification.SessionVerificationService
 import kotlinx.coroutines.CoroutineScope
@@ -68,4 +69,9 @@ object SessionMatrixModule {
     fun provideSessionCoroutineScope(matrixClient: MatrixClient): CoroutineScope {
         return matrixClient.sessionCoroutineScope
     }
+
+    @Provides
+    fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService {
+        return matrixClient.roomDirectoryService()
+    }
 }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt
new file mode 100644
index 00000000000..876a58d3a5d
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import org.matrix.rustcomponents.sdk.PublicRoomJoinRule
+import org.matrix.rustcomponents.sdk.RoomDescription as RustRoomDescription
+
+class RoomDescriptionMapper {
+    fun map(roomDescription: RustRoomDescription): RoomDescription {
+        return RoomDescription(
+            roomId = RoomId(roomDescription.roomId),
+            name = roomDescription.name,
+            topic = roomDescription.topic,
+            avatarUrl = roomDescription.avatarUrl,
+            alias = roomDescription.alias,
+            joinRule = when (roomDescription.joinRule) {
+                PublicRoomJoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC
+                PublicRoomJoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK
+                null -> RoomDescription.JoinRule.UNKNOWN
+            },
+            isWorldReadable = roomDescription.isWorldReadable,
+            numberOfMembers = roomDescription.joinedMembers.toLong(),
+        )
+    }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt
new file mode 100644
index 00000000000..28e82f4e40b
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+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.RoomDirectorySearch
+import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntriesListener
+import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate
+import timber.log.Timber
+
+internal fun RoomDirectorySearch.resultsFlow(): Flow<List<RoomDirectorySearchEntryUpdate>> =
+    callbackFlow {
+        val listener = object : RoomDirectorySearchEntriesListener {
+            override fun onUpdate(roomEntriesUpdate: List<RoomDirectorySearchEntryUpdate>) {
+                trySendBlocking(roomEntriesUpdate)
+            }
+        }
+        val result = results(listener)
+        awaitClose {
+            result.cancelAndDestroy()
+        }
+    }.catch {
+        Timber.d(it, "timelineDiffFlow() failed")
+    }.buffer(Channel.UNLIMITED)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt
new file mode 100644
index 00000000000..aff631d6b44
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate
+import timber.log.Timber
+import kotlin.coroutines.CoroutineContext
+
+class RoomDirectorySearchProcessor(
+    private val roomDescriptions: MutableSharedFlow<List<RoomDescription>>,
+    private val coroutineContext: CoroutineContext,
+    private val roomDescriptionMapper: RoomDescriptionMapper,
+) {
+    private val mutex = Mutex()
+
+    suspend fun postUpdates(updates: List<RoomDirectorySearchEntryUpdate>) {
+        updateRoomDescriptions {
+            Timber.v("Update room descriptions from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
+            updates.forEach { update ->
+                applyUpdate(update)
+            }
+        }
+    }
+
+    private fun MutableList<RoomDescription>.applyUpdate(update: RoomDirectorySearchEntryUpdate) {
+        when (update) {
+            is RoomDirectorySearchEntryUpdate.Append -> {
+                val roomSummaries = update.values.map(roomDescriptionMapper::map)
+                addAll(roomSummaries)
+            }
+            is RoomDirectorySearchEntryUpdate.PushBack -> {
+                val roomDescription = roomDescriptionMapper.map(update.value)
+                add(roomDescription)
+            }
+            is RoomDirectorySearchEntryUpdate.PushFront -> {
+                val roomDescription = roomDescriptionMapper.map(update.value)
+                add(0, roomDescription)
+            }
+            is RoomDirectorySearchEntryUpdate.Set -> {
+                val roomDescription = roomDescriptionMapper.map(update.value)
+                this[update.index.toInt()] = roomDescription
+            }
+            is RoomDirectorySearchEntryUpdate.Insert -> {
+                val roomDescription = roomDescriptionMapper.map(update.value)
+                add(update.index.toInt(), roomDescription)
+            }
+            is RoomDirectorySearchEntryUpdate.Remove -> {
+                removeAt(update.index.toInt())
+            }
+            is RoomDirectorySearchEntryUpdate.Reset -> {
+                clear()
+                addAll(update.values.map(roomDescriptionMapper::map))
+            }
+            RoomDirectorySearchEntryUpdate.PopBack -> {
+                removeLastOrNull()
+            }
+            RoomDirectorySearchEntryUpdate.PopFront -> {
+                removeFirstOrNull()
+            }
+            RoomDirectorySearchEntryUpdate.Clear -> {
+                clear()
+            }
+            is RoomDirectorySearchEntryUpdate.Truncate -> {
+                subList(update.length.toInt(), size).clear()
+            }
+        }
+    }
+
+    private suspend fun updateRoomDescriptions(block: suspend MutableList<RoomDescription>.() -> Unit) = withContext(coroutineContext) {
+        mutex.withLock {
+            val current = roomDescriptions.replayCache.lastOrNull()
+            val mutableRoomSummaries = current.orEmpty().toMutableList()
+            block(mutableRoomSummaries)
+            roomDescriptions.emit(mutableRoomSummaries)
+        }
+    }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
new file mode 100644
index 00000000000..9b444d5ce5e
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.matrix.rustcomponents.sdk.RoomDirectorySearch
+import kotlin.coroutines.CoroutineContext
+
+class RustRoomDirectoryList(
+    private val inner: RoomDirectorySearch,
+    coroutineScope: CoroutineScope,
+    private val coroutineContext: CoroutineContext,
+) : RoomDirectoryList {
+    private val hasMoreToLoad = MutableStateFlow(true)
+    private val items = MutableSharedFlow<List<RoomDescription>>(replay = 1)
+    private val processor = RoomDirectorySearchProcessor(items, coroutineContext, RoomDescriptionMapper())
+
+    init {
+        launchIn(coroutineScope)
+    }
+
+    private fun launchIn(coroutineScope: CoroutineScope) {
+        inner
+            .resultsFlow()
+            .onEach { updates ->
+                processor.postUpdates(updates)
+            }
+            .flowOn(coroutineContext)
+            .launchIn(coroutineScope)
+    }
+
+    override suspend fun filter(filter: String?, batchSize: Int): Result<Unit> {
+        return execute {
+            inner.search(filter = filter, batchSize = batchSize.toUInt())
+        }
+    }
+
+    override suspend fun loadMore(): Result<Unit> {
+        return execute {
+            inner.nextPage()
+        }
+    }
+
+    private suspend fun execute(action: suspend () -> Unit): Result<Unit> {
+        return try {
+            // We always assume there is more to load until we know there isn't.
+            // As accessing hasMoreToLoad is otherwise blocked by the current action.
+            hasMoreToLoad.value = true
+            action()
+            Result.success(Unit)
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            Result.failure(e)
+        } finally {
+            hasMoreToLoad.value = hasMoreToLoad()
+        }
+    }
+
+    private suspend fun hasMoreToLoad(): Boolean {
+        return !inner.isAtLastPage()
+    }
+
+    override val state: Flow<RoomDirectoryList.State> =
+        combine(hasMoreToLoad, items) { hasMoreToLoad, items ->
+            RoomDirectoryList.State(
+                hasMoreToLoad = hasMoreToLoad,
+                items = items
+            )
+        }
+            .flowOn(coroutineContext)
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt
new file mode 100644
index 00000000000..2939001b210
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import org.matrix.rustcomponents.sdk.Client
+
+class RustRoomDirectoryService(
+    private val client: Client,
+    private val sessionDispatcher: CoroutineDispatcher,
+) : RoomDirectoryService {
+    override fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList {
+        return RustRoomDirectoryList(client.roomDirectorySearch(), scope, sessionDispatcher)
+    }
+}
diff --git a/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml
index f163de25408..70641271b88 100644
--- a/libraries/ui-strings/src/main/res/values-be/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-be/translations.xml
@@ -258,8 +258,6 @@
   <string name="screen_media_picker_error_failed_selection">"Не ўдалося выбраць носьбіт, паўтарыце спробу."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
-  <string name="screen_room_directory_search_loading_error">"Памылка загрузкі"</string>
-  <string name="screen_room_directory_search_title">"Каталог пакояў"</string>
   <string name="screen_room_error_failed_processing_media">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Не ўдалося атрымаць інфармацыю пра карыстальніка"</string>
   <string name="screen_room_member_details_block_alert_action">"Заблакіраваць"</string>
diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml
index 6975c64660f..9dde757dc18 100644
--- a/libraries/ui-strings/src/main/res/values-cs/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml
@@ -258,8 +258,6 @@
   <string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
-  <string name="screen_room_directory_search_loading_error">"Načítání se nezdařilo"</string>
-  <string name="screen_room_directory_search_title">"Adresář místností"</string>
   <string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
   <string name="screen_room_member_details_block_alert_action">"Zablokovat"</string>
diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml
index 83b3dcac0aa..8a3e035833a 100644
--- a/libraries/ui-strings/src/main/res/values-de/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-de/translations.xml
@@ -254,8 +254,6 @@
   <string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>
-  <string name="screen_room_directory_search_loading_error">"Fehler beim Laden"</string>
-  <string name="screen_room_directory_search_title">"Raumverzeichnis"</string>
   <string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string>
   <string name="screen_room_member_details_block_alert_action">"Blockieren"</string>
diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml
index 219b8501c5d..e3b6e3d0d94 100644
--- a/libraries/ui-strings/src/main/res/values-fr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml
@@ -254,8 +254,6 @@
   <string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Échec du téléchargement du média, veuillez réessayer."</string>
-  <string name="screen_room_directory_search_loading_error">"Échec du chargement"</string>
-  <string name="screen_room_directory_search_title">"Annuaire des salons"</string>
   <string name="screen_room_error_failed_processing_media">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Impossible de récupérer les détails de l’utilisateur"</string>
   <string name="screen_room_member_details_block_alert_action">"Bloquer"</string>
diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml
index 01a23b4c247..7758a05eeb1 100644
--- a/libraries/ui-strings/src/main/res/values-ru/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml
@@ -256,8 +256,6 @@
   <string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
-  <string name="screen_room_directory_search_loading_error">"Сбой загрузки"</string>
-  <string name="screen_room_directory_search_title">"Каталог комнат"</string>
   <string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
   <string name="screen_room_member_details_block_alert_action">"Заблокировать"</string>
diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml
index 9c1df72eafe..f49201bda13 100644
--- a/libraries/ui-strings/src/main/res/values-sk/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml
@@ -257,8 +257,6 @@
   <string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
-  <string name="screen_room_directory_search_loading_error">"Načítanie zlyhalo"</string>
-  <string name="screen_room_directory_search_title">"Adresár miestností"</string>
   <string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string>
   <string name="screen_room_member_details_block_alert_action">"Zablokovať"</string>
diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml
index 542ff641aa2..a97f92401a9 100644
--- a/libraries/ui-strings/src/main/res/values-uk/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml
@@ -256,8 +256,6 @@
   <string name="screen_media_picker_error_failed_selection">"Не вдалося вибрати медіафайл, спробуйте ще раз."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Не вдалося завантажити медіафайл, спробуйте ще раз."</string>
-  <string name="screen_room_directory_search_loading_error">"Не вдалося завантажити"</string>
-  <string name="screen_room_directory_search_title">"Каталог кімнат"</string>
   <string name="screen_room_error_failed_processing_media">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Не вдалося отримати дані користувача"</string>
   <string name="screen_room_member_details_block_alert_action">"Заблокувати"</string>
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 97ddec4b3b2..1bc20364ce7 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -254,8 +254,6 @@
   <string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
   <string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
   <string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
-  <string name="screen_room_directory_search_loading_error">"Failed loading"</string>
-  <string name="screen_room_directory_search_title">"Room directory"</string>
   <string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
   <string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
   <string name="screen_room_member_details_block_alert_action">"Block"</string>
@@ -274,4 +272,55 @@
   <string name="settings_version_number">"Version: %1$s (%2$s)"</string>
   <string name="test_language_identifier">"en"</string>
   <string name="test_untranslated_default_language_identifier">"en"</string>
+  <string name="troubleshoot_notifications_entry_point_section">"Troubleshoot"</string>
+  <string name="troubleshoot_notifications_entry_point_title">"Troubleshoot notifications"</string>
+  <string name="troubleshoot_notifications_screen_action">"Run tests"</string>
+  <string name="troubleshoot_notifications_screen_action_again">"Run tests again"</string>
+  <string name="troubleshoot_notifications_screen_failure">"Some tests failed. Please check the details."</string>
+  <string name="troubleshoot_notifications_screen_notice">"Run the tests to detect any issue in your configuration that may make notifications not behave as expected."</string>
+  <string name="troubleshoot_notifications_screen_quick_fix_action">"Attempt to fix"</string>
+  <string name="troubleshoot_notifications_screen_success">"All tests passed successfully."</string>
+  <string name="troubleshoot_notifications_screen_title">"Troubleshoot notifications"</string>
+  <string name="troubleshoot_notifications_screen_waiting">"Some tests require your attention. Please check the details."</string>
+  <string name="troubleshoot_notifications_test_check_permission_description">"Check that the application can show notifications."</string>
+  <string name="troubleshoot_notifications_test_check_permission_title">"Check permissions"</string>
+  <string name="troubleshoot_notifications_test_current_push_provider_description">"Get the name of the current provider."</string>
+  <string name="troubleshoot_notifications_test_current_push_provider_failure">"No push providers selected."</string>
+  <string name="troubleshoot_notifications_test_current_push_provider_success">"Current push provider: %1$s."</string>
+  <string name="troubleshoot_notifications_test_current_push_provider_title">"Current push provider"</string>
+  <string name="troubleshoot_notifications_test_detect_push_provider_description">"Ensure that the application has at least one push provider."</string>
+  <string name="troubleshoot_notifications_test_detect_push_provider_failure">"No push providers found."</string>
+  <plurals name="troubleshoot_notifications_test_detect_push_provider_success">
+    <item quantity="one">"Found %1$d push provider: %2$s"</item>
+    <item quantity="other">"Found %1$d push providers: %2$s"</item>
+  </plurals>
+  <string name="troubleshoot_notifications_test_detect_push_provider_title">"Detect push providers"</string>
+  <string name="troubleshoot_notifications_test_display_notification_description">"Check that the application can display notification."</string>
+  <string name="troubleshoot_notifications_test_display_notification_failure">"The notification has not been clicked."</string>
+  <string name="troubleshoot_notifications_test_display_notification_permission_failure">"Cannot display the notification."</string>
+  <string name="troubleshoot_notifications_test_display_notification_success">"The notification has been clicked!"</string>
+  <string name="troubleshoot_notifications_test_display_notification_title">"Display notification"</string>
+  <string name="troubleshoot_notifications_test_display_notification_waiting">"Please click on the notification to continue the test."</string>
+  <string name="troubleshoot_notifications_test_firebase_availability_description">"Ensure that Firebase is available."</string>
+  <string name="troubleshoot_notifications_test_firebase_availability_failure">"Firebase is not available."</string>
+  <string name="troubleshoot_notifications_test_firebase_availability_success">"Firebase is available."</string>
+  <string name="troubleshoot_notifications_test_firebase_availability_title">"Check Firebase"</string>
+  <string name="troubleshoot_notifications_test_firebase_token_description">"Ensure that Firebase token is available."</string>
+  <string name="troubleshoot_notifications_test_firebase_token_failure">"Firebase token is not known."</string>
+  <string name="troubleshoot_notifications_test_firebase_token_success">"Firebase token: %1$s."</string>
+  <string name="troubleshoot_notifications_test_firebase_token_title">"Check Firebase token"</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_description">"Ensure that the application is receiving push."</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Error: pusher has rejected the request."</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Error: %1$s."</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Error, cannot test push."</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Error, timeout waiting for push."</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_success">"Push loop back took %1$d ms."</string>
+  <string name="troubleshoot_notifications_test_push_loop_back_title">"Test Push loop back"</string>
+  <string name="troubleshoot_notifications_test_unified_push_description">"Ensure that UnifiedPush distributors are available."</string>
+  <string name="troubleshoot_notifications_test_unified_push_failure">"No push distributors found."</string>
+  <plurals name="troubleshoot_notifications_test_unified_push_success">
+    <item quantity="one">"%1$d distributor found: %2$s."</item>
+    <item quantity="other">"%1$d distributors found: %2$s."</item>
+  </plurals>
+  <string name="troubleshoot_notifications_test_unified_push_title">"Check UnifiedPush"</string>
 </resources>
diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
index e651e8f968e..827038785cc 100644
--- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
+++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
@@ -118,7 +118,8 @@ class RoomListScreen(
                 roomListService = matrixClient.roomListService,
                 roomSummaryFactory = roomListRoomSummaryFactory,
                 coroutineDispatchers = coroutineDispatchers,
-            )
+            ),
+            featureFlagService = featureFlagService,
         ),
         sessionPreferencesStore = DefaultSessionPreferencesStore(
             context = context,
@@ -156,6 +157,7 @@ class RoomListScreen(
             onInvitesClicked = {},
             onRoomSettingsClicked = {},
             onMenuActionClicked = {},
+            onRoomDirectorySearchClicked = {},
             modifier = modifier,
         )

diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index b3ad636c86a..aaa1f6734b0 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -197,6 +197,12 @@
         "screen_app_lock_.*",
         "screen_signout_in_progress_dialog_content"
       ]
+    },
+    {
+      "name" : ":features:roomdirectory:impl",
+      "includeRegex" : [
+        "screen_room_directory_.*"
+      ]
     }
   ]
 }

diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml
index 8b41c4d259f..5d5e01de84d 100644
--- a/.maestro/tests/roomList/searchRoomList.yaml
+++ b/.maestro/tests/roomList/searchRoomList.yaml
@@ -7,8 +7,4 @@ appId: ${MAESTRO_APP_ID}
 - tapOn: ${MAESTRO_ROOM_NAME}
 # Back from timeline
 - back
-- assertVisible: "MyR"
-- hideKeyboard
-# Back from search
-- back
 - runFlow: ../assertions/assertHomeDisplayed.yaml
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
new file mode 100644
index 00000000000..3f4d17aefd0
--- /dev/null
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
+import io.element.android.libraries.matrix.api.core.RoomId
+
+class FakeJoinRoom(
+    var lambda: (RoomId) -> Result<RoomId> = { Result.success(it) }
+) : JoinRoom {
+    override suspend fun invoke(roomId: RoomId) = lambda(roomId)
+}
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
new file mode 100644
index 00000000000..eefafc86e1d
--- /dev/null
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryList
+import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService
+import io.element.android.libraries.matrix.test.roomdirectory.aRoomDescription
+import io.element.android.tests.testutils.lambda.any
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class) class RoomDirectoryPresenterTest {
+    @Test
+    fun `present - initial state`() = runTest {
+        val presenter = createRoomDirectoryPresenter()
+        presenter.test {
+            val initialState = awaitItem()
+            assertThat(initialState.query).isEmpty()
+            assertThat(initialState.displayEmptyState).isFalse()
+            assertThat(initialState.joinRoomAction).isEqualTo(AsyncAction.Uninitialized)
+            assertThat(initialState.roomDescriptions).isEmpty()
+            assertThat(initialState.displayLoadMoreIndicator).isTrue()
+        }
+    }
+
+    @Test
+    fun `present - room directory list emits empty state`() = runTest {
+        val directoryListStateFlow = MutableSharedFlow<RoomDirectoryList.State>(replay = 1)
+        val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow)
+        val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+        val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+        presenter.test {
+            skipItems(1)
+            directoryListStateFlow.emit(
+                RoomDirectoryList.State(false, emptyList())
+            )
+            awaitItem().also { state ->
+                assertThat(state.displayEmptyState).isTrue()
+            }
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun `present - room directory list emits non-empty state`() = runTest {
+        val directoryListStateFlow = MutableSharedFlow<RoomDirectoryList.State>(replay = 1)
+        val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow)
+        val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+        val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+        presenter.test {
+            skipItems(1)
+            directoryListStateFlow.emit(
+                RoomDirectoryList.State(
+                    hasMoreToLoad = true,
+                    items = listOf(aRoomDescription())
+                )
+            )
+            awaitItem().also { state ->
+                assertThat(state.displayEmptyState).isFalse()
+                assertThat(state.roomDescriptions).hasSize(1)
+            }
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun `present - emit search event`() = runTest {
+        val filterLambda = lambdaRecorder { _: String?, _: Int ->
+            Result.success(Unit)
+        }
+        val roomDirectoryList = FakeRoomDirectoryList(filterLambda = filterLambda)
+        val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+        val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+        presenter.test {
+            awaitItem().also { state ->
+                state.eventSink(RoomDirectoryEvents.Search("test"))
+            }
+            awaitItem().also { state ->
+                assertThat(state.query).isEqualTo("test")
+            }
+            advanceUntilIdle()
+            cancelAndIgnoreRemainingEvents()
+        }
+        assert(filterLambda)
+            .isCalledOnce()
+            .with(value("test"), any())
+    }
+
+    @Test
+    fun `present - emit load more event`() = runTest {
+        val loadMoreLambda = lambdaRecorder { ->
+            Result.success(Unit)
+        }
+        val roomDirectoryList = FakeRoomDirectoryList(loadMoreLambda = loadMoreLambda)
+        val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+        val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+        presenter.test {
+            awaitItem().also { state ->
+                state.eventSink(RoomDirectoryEvents.LoadMore)
+            }
+            advanceUntilIdle()
+            cancelAndIgnoreRemainingEvents()
+        }
+        assert(loadMoreLambda)
+            .isCalledOnce()
+            .withNoParameter()
+    }
+
+    @Test
+    fun `present - emit join room event`() = runTest {
+        val joinRoomSuccess = lambdaRecorder { roomId: RoomId ->
+            Result.success(roomId)
+        }
+        val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
+            Result.failure<RoomId>(RuntimeException("Failed to join room $roomId"))
+        }
+        val fakeJoinRoom = FakeJoinRoom(joinRoomSuccess)
+        val presenter = createRoomDirectoryPresenter(joinRoom = fakeJoinRoom)
+        presenter.test {
+            awaitItem().also { state ->
+                state.eventSink(RoomDirectoryEvents.JoinRoom(A_ROOM_ID))
+            }
+            awaitItem().also { state ->
+                assertThat(state.joinRoomAction).isEqualTo(AsyncAction.Success(A_ROOM_ID))
+                fakeJoinRoom.lambda = joinRoomFailure
+                state.eventSink(RoomDirectoryEvents.JoinRoom(A_ROOM_ID))
+            }
+            awaitItem().also { state ->
+                assertThat(state.joinRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+            }
+        }
+        assert(joinRoomSuccess)
+            .isCalledOnce()
+            .with(value(A_ROOM_ID))
+        assert(joinRoomFailure)
+            .isCalledOnce()
+            .with(value(A_ROOM_ID))
+    }
+
+    private fun TestScope.createRoomDirectoryPresenter(
+        roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(
+            createRoomDirectoryListFactory = { FakeRoomDirectoryList() }
+        ),
+        joinRoom: JoinRoom = FakeJoinRoom { Result.success(it) },
+    ): RoomDirectoryPresenter {
+        return RoomDirectoryPresenter(
+            dispatchers = testCoroutineDispatchers(),
+            joinRoom = joinRoom,
+            roomDirectoryService = roomDirectoryService,
+        )
+    }
+}
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
new file mode 100644
index 00000000000..bcac35fc3a6
--- /dev/null
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoomDirectoryViewTest {
+    @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
+
+    @Test
+    fun `typing text in search field emits the expected Event`() {
+        val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
+        rule.setRoomDirectoryView(
+            state = aRoomDirectoryState(
+                eventSink = eventsRecorder,
+            )
+        )
+        rule.onNodeWithTag(TestTags.searchTextField.value).performTextInput(
+            text = "Test"
+        )
+        eventsRecorder.assertSingle(RoomDirectoryEvents.Search("Test"))
+    }
+
+    @Test
+    fun `clicking on room item emits the expected Event`() {
+        val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
+        val state = aRoomDirectoryState(
+            roomDescriptions = aRoomDescriptionList(),
+            eventSink = eventsRecorder,
+        )
+        rule.setRoomDirectoryView(state = state)
+        val clickedRoom = state.roomDescriptions.first()
+        rule.onNodeWithText(clickedRoom.name).performClick()
+        eventsRecorder.assertSingle(RoomDirectoryEvents.JoinRoom(clickedRoom.roomId))
+    }
+
+    @Test
+    fun `composing load more indicator emits expected Event`() {
+        val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
+        val state = aRoomDirectoryState(
+            displayLoadMoreIndicator = true,
+            eventSink = eventsRecorder,
+        )
+        rule.setRoomDirectoryView(state = state)
+        eventsRecorder.assertSingle(RoomDirectoryEvents.LoadMore)
+    }
+
+    @Test
+    fun `when joining room with success then onRoomJoined lambda is called once`() {
+        val eventsRecorder = EventsRecorder<RoomDirectoryEvents>(expectEvents = false)
+        val roomDescriptions = aRoomDescriptionList()
+        val joinedRoomId = roomDescriptions.first().roomId
+        val state = aRoomDirectoryState(
+            joinRoomAction = AsyncAction.Success(joinedRoomId),
+            roomDescriptions = roomDescriptions,
+            eventSink = eventsRecorder,
+        )
+        ensureCalledOnceWithParam(joinedRoomId) { callback ->
+            rule.setRoomDirectoryView(
+                state = state,
+                onRoomJoined = callback,
+            )
+        }
+    }
+}
+
+private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDirectoryView(
+    state: RoomDirectoryState,
+    onBackPressed: () -> Unit = EnsureNeverCalled(),
+    onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+) {
+    setContent {
+        RoomDirectoryView(
+            state = state,
+            onRoomJoined = onRoomJoined,
+            onBackPressed = onBackPressed,
+        )
+    }
+}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
index f545b278608..c860b6fc423 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
@@ -191,6 +191,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
     onInvitesClicked: () -> Unit = EnsureNeverCalled(),
     onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
     onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
+    onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
 ) {
     setContent {
         RoomListView(
@@ -203,6 +204,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
             onInvitesClicked = onInvitesClicked,
             onRoomSettingsClicked = onRoomSettingsClicked,
             onMenuActionClicked = onMenuActionClicked,
+            onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
         )
     }
 }
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt
index d3fc434f258..b3463c549fe 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt
@@ -23,6 +23,9 @@ import com.google.common.truth.Truth.assertThat
 import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
 import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
 import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
 import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@@ -128,10 +131,26 @@ class RoomListSearchPresenterTests {
             }
         }
     }
+
+    @Test
+    fun `present - room directory search`() = runTest {
+        val featureFlagService = FakeFeatureFlagService()
+        featureFlagService.setFeatureEnabled(FeatureFlags.RoomDirectorySearch, true)
+        val presenter = createRoomListSearchPresenter(featureFlagService = featureFlagService)
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            skipItems(1)
+            awaitItem().let { state ->
+                assertThat(state.isRoomDirectorySearchEnabled).isTrue()
+            }
+        }
+    }
 }

 fun TestScope.createRoomListSearchPresenter(
     roomListService: RoomListService = FakeRoomListService(),
+    featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
 ): RoomListSearchPresenter {
     return RoomListSearchPresenter(
         dataSource = RoomListSearchDataSource(
@@ -141,6 +160,7 @@ fun TestScope.createRoomListSearchPresenter(
                 roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
             ),
             coroutineDispatchers = testCoroutineDispatchers(),
-        )
+        ),
+        featureFlagService = featureFlagService,
     )
 }
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index d7c3f71e647..247bc4bbe9f 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
 import io.element.android.libraries.matrix.api.pusher.PushersService
 import io.element.android.libraries.matrix.api.room.MatrixRoom
 import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
 import io.element.android.libraries.matrix.api.roomlist.RoomListService
 import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
 import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaLoader
 import io.element.android.libraries.matrix.test.notification.FakeNotificationService
 import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
 import io.element.android.libraries.matrix.test.pushers.FakePushersService
+import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService
 import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
 import io.element.android.libraries.matrix.test.sync.FakeSyncService
 import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -65,6 +67,7 @@ class FakeMatrixClient(
     private val notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
     private val syncService: FakeSyncService = FakeSyncService(),
     private val encryptionService: FakeEncryptionService = FakeEncryptionService(),
+    private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
     private val accountManagementUrlString: Result<String?> = Result.success(null),
 ) : MatrixClient {
     var setDisplayNameCalled: Boolean = false
@@ -91,6 +94,9 @@ class FakeMatrixClient(
     private var setDisplayNameResult: Result<Unit> = Result.success(Unit)
     private var uploadAvatarResult: Result<Unit> = Result.success(Unit)
     private var removeAvatarResult: Result<Unit> = Result.success(Unit)
+    var joinRoomLambda: suspend (RoomId) -> Result<RoomId> = {
+        Result.success(it)
+    }

     override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
         return getRoomResults[roomId]
@@ -126,6 +132,8 @@ class FakeMatrixClient(

     override fun syncService() = syncService

+    override fun roomDirectoryService() = roomDirectoryService
+
     override suspend fun getCacheSize(): Long {
         return 0
     }
@@ -176,6 +184,8 @@ class FakeMatrixClient(
         return removeAvatarResult
     }

+    override suspend fun joinRoom(roomId: RoomId): Result<RoomId> = joinRoomLambda(roomId)
+
     override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService

     override fun pushersService(): PushersService = pushersService
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt
new file mode 100644
index 00000000000..b01501d3282
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+
+class FakeRoomDirectoryList(
+    override val state: Flow<RoomDirectoryList.State> = emptyFlow(),
+    val filterLambda: (String?, Int) -> Result<Unit> = { _, _ -> Result.success(Unit) },
+    val loadMoreLambda: () -> Result<Unit> = { Result.success(Unit) }
+) : RoomDirectoryList {
+    override suspend fun filter(filter: String?, batchSize: Int) = filterLambda(filter, batchSize)
+
+    override suspend fun loadMore(): Result<Unit> = loadMoreLambda()
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt
new file mode 100644
index 00000000000..68926b9deb2
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import kotlinx.coroutines.CoroutineScope
+
+class FakeRoomDirectoryService(
+    private val createRoomDirectoryListFactory: (CoroutineScope) -> RoomDirectoryList = { throw AssertionError("Configure a proper factory.") }
+) : RoomDirectoryService {
+    override fun createRoomDirectoryList(scope: CoroutineScope) = createRoomDirectoryListFactory(scope)
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt
new file mode 100644
index 00000000000..6e96ca44526
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.roomdirectory
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+
+fun aRoomDescription(
+    roomId: RoomId = A_ROOM_ID,
+    name: String? = null,
+    topic: String? = null,
+    alias: String? = null,
+    avatarUrl: String? = null,
+    joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN,
+    isWorldReadable: Boolean = true,
+    joinedMembers: Long = 2L
+) = RoomDescription(
+    roomId = roomId,
+    name = name,
+    topic = topic,
+    alias = alias,
+    avatarUrl = avatarUrl,
+    joinRule = joinRule,
+    isWorldReadable = isWorldReadable,
+    numberOfMembers = joinedMembers
+)
diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt
index 1d237979e65..4374d77e52e 100644
--- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt
+++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt
@@ -100,4 +100,9 @@ object TestTags {
      * Timeline item.
      */
     val timelineItemSenderInfo = TestTag("timeline_item-sender_info")
+
+    /**
+     * Search field.
+     */
+    val searchTextField = TestTag("search_text_field")
 }
diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/PresenterTest.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/PresenterTest.kt
new file mode 100644
index 00000000000..27358271345
--- /dev/null
+++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/PresenterTest.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.tests.testutils
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.TurbineTestContext
+import app.cash.turbine.test
+import io.element.android.libraries.architecture.Presenter
+import kotlin.time.Duration
+
+suspend fun <State> Presenter<State>.test(
+    timeout: Duration? = null,
+    name: String? = null,
+    validate: suspend TurbineTestContext<State>.() -> Unit,
+) {
+    moleculeFlow(RecompositionMode.Immediate) {
+        present()
+    }.test(timeout, name, validate)
+}
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..bd44675e52f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1b6110eb2eb66c404f787472dd18608a27a3c8ce19c794ed4b3f67e186720978
+size 13703
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..8d0538f7927
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c2b6cc7995880af654439c4f45d6e3ff35ff9c92609ad136fcdfbb2050edea44
+size 30484
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..158c2f7cf50
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1f7885c0ee25d12dc5bb25940b35e7153f95ffc35cbe6fac21839390d52faeac
+size 32326
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..c77504c1866
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:776d8f8a15bac930896a8baaf46f69fddf24fdf5c51c1ab6f1bbc48ef89259d6
+size 31607
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_4,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..0eb807db6dd
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Day-0_1_null_4,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b349aa0ce78db6396094adda401f52133c32c16e695a051ba115be0143c31de9
+size 34972
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..ab56f083482
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9eafd587065c22d19814285b63e7690f5ff4bd1ed0765bd9802e61d6d8ed0ba7
+size 12915
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..1ee85c5f138
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7a7b5c11dbc45a804d34a0105c49c7bd15ab1f5d6441f0e95dba84fd8a9eada6
+size 29080
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..80d7eb5d17e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6af6d92766e7249ee95582a32ac17ddd5c3bcc29c9b78d9ba9ca6bcb27b69c8c
+size 30828
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..34fa07061ae
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b434b666666da81963d8ac89e9669f059443cd529103735944e6a15ba5ba46b5
+size 28646
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_4,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..cbf54de7294
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdirectory.impl.root_RoomDirectoryView_null_RoomDirectoryView-Night-0_2_null_4,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f0190f4e7d06f5b46147531d7224e24662e0a7ef6289d516cf082d59ab63627f
+size 31342
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_1,NEXUS_5,1.0,en].png
index 5cc02790771..872a2f22747 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_1,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_1,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:1625ac34428f660c235d1d64f5de867baa6c0ca296f0a93d81588d633d6a74bf
-size 30082
+oid sha256:615e00dfa47d40fd459d268d6e68c61a501be7e4a24bfe7d18f3a647da8c5f08
+size 10595
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..5cc02790771
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-13_14_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1625ac34428f660c235d1d64f5de867baa6c0ca296f0a93d81588d633d6a74bf
+size 30082
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_1,NEXUS_5,1.0,en].png
index f7230cc7745..845fd9fc0aa 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_1,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_1,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:264d9373767b6b59d0cea4bfa4d148a453058be70e7805581fe96d7448ef5232
-size 29978
+oid sha256:421e47de6c2bc7fd0a5de84b6db4bea79c7312442244ec11506164150762943b
+size 9787
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..f7230cc7745
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-13_15_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:264d9373767b6b59d0cea4bfa4d148a453058be70e7805581fe96d7448ef5232
+size 29978
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_60,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_60,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..ec81aa14bc1
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_60,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c7c46e25d0b339f237f5c5d98d850664638b2084b3fe4a75e5d7b50d3b678b8b
+size 18999
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_61,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_61,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..27df9475681
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_61,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:150055f181351f24e22ba29492904b2296edc7c826aaa81952216734ec4431f9
+size 18098
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_62,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_62,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..0f2ea2dda38
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_Avatar_null_Avatars_Avatar_0_null_62,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a623b5b5bd5700f38b9eb790a37a5b029e1a74b8ae9811f9732dec808cacc770
+size 21231