diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 8d81632f83..fe63bb677d 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml
index 8b41c4d259..5d5e01de84 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/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index e4290d5bdf..a9dd485285 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(
@@ -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().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 cde723257f..b6bf76c440 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 @@
"Demote"
"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."
"Demote yourself?"
+ "%1$s (Pending)"
"Edit Moderators"
+ "Admins"
+ "Moderators"
+ "Members"
"You have unsaved changes."
"Save changes?"
"Add topic"
diff --git a/features/roomdirectory/api/build.gradle.kts b/features/roomdirectory/api/build.gradle.kts
new file mode 100644
index 0000000000..04a813bd0f
--- /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 0000000000..5b945b2a7d
--- /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 0000000000..5a693a4a83
--- /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 0000000000..85bb195da3
--- /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 0000000000..c15a748a9e
--- /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()
+
+ return object : RoomDirectoryEntryPoint.NodeBuilder {
+ override fun callback(callback: RoomDirectoryEntryPoint.Callback): RoomDirectoryEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(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 0000000000..37e0ffb3c6
--- /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 0000000000..dc3581589e
--- /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,
+ private val presenter: RoomDirectoryPresenter,
+) : Node(buildContext, plugins = plugins) {
+ private fun onRoomJoined(roomId: RoomId) {
+ plugins().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 0000000000..5d4cef55cb
--- /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 {
+ @Composable
+ override fun present(): RoomDirectoryState {
+ var loadingMore by remember {
+ mutableStateOf(false)
+ }
+ var searchQuery by rememberSaveable {
+ mutableStateOf(null)
+ }
+ val coroutineScope = rememberCoroutineScope()
+ val roomDirectoryList = remember {
+ roomDirectoryService.createRoomDirectoryList(coroutineScope)
+ }
+ val listState by roomDirectoryList.collectState()
+ val joinRoomAction: MutableState> = 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>, 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 0000000000..526139338d
--- /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,
+ val displayLoadMoreIndicator: Boolean,
+ val joinRoomAction: AsyncAction,
+ 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 0000000000..efb6624260
--- /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 {
+ override val values: Sequence
+ 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 = persistentListOf(),
+ joinRoomAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (RoomDirectoryEvents) -> Unit = {},
+) = RoomDirectoryState(
+ query = query,
+ roomDescriptions = roomDescriptions,
+ displayLoadMoreIndicator = displayLoadMoreIndicator,
+ joinRoomAction = joinRoomAction,
+ eventSink = eventSink,
+)
+
+fun aRoomDescriptionList(): ImmutableList {
+ 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 0000000000..f6188c3269
--- /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,
+ 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 0000000000..983d2a1dd2
--- /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
+}
+
+@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 0000000000..be36eb5053
--- /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 0000000000..60f344f67b
--- /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,
+) {
+ 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 0000000000..d3fb9d15ae
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,5 @@
+
+
+ "Failed loading"
+ "Room directory"
+
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 0000000000..3f4d17aefd
--- /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 = { 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 0000000000..eefafc86e1
--- /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(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(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(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 0000000000..bcac35fc3a
--- /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()
+
+ @Test
+ fun `typing text in search field emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ 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()
+ 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()
+ 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(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 AndroidComposeTestRule.setRoomDirectoryView(
+ state: RoomDirectoryState,
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+ onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+) {
+ setContent {
+ RoomDirectoryView(
+ state = state,
+ onRoomJoined = onRoomJoined,
+ onBackPressed = onBackPressed,
+ )
+ }
+}
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 0404ce18fc..c3b2ff36a2 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 5dec53e158..a9740bcfcb 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().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 23a105f143..e0a3fc5201 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 78bcda07f1..4ae7f091fe 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 {
@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 c4b24dc798..92e70ad039 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,
+ 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 ae722a4b04..c4dcbab1ec 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
get() = sequenceOf(
aRoomListSearchState(),
+ aRoomListSearchState(isRoomDirectorySearchEnabled = true),
aRoomListSearchState(
isSearchActive = true,
query = "Test",
@@ -38,10 +39,12 @@ fun aRoomListSearchState(
isSearchActive: Boolean = false,
query: String = "",
results: ImmutableList = 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 eff6449449..80657ed4fc 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/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 f545b27860..c860b6fc42 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 AndroidComposeTestRule.setRoomL
onInvitesClicked: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
+ onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomListView(
@@ -203,6 +204,7 @@ private fun AndroidComposeTestRule.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 d3fc434f25..b3463c549f 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/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 0451cb5839..2dc6c8875f 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 a242d014d1..5642eefbd2 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 4e7032a313..e442a1e960 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 955c8d72fa..e6c536ee72 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
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result
suspend fun removeAvatar(): Result
+ suspend fun joinRoom(roomId: RoomId): Result
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 0000000000..78d6cb0c94
--- /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 0000000000..2311c5afee
--- /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
+ suspend fun loadMore(): Result
+ val state: Flow
+
+ data class State(
+ val hasMoreToLoad: Boolean,
+ val items: List,
+ )
+}
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 0000000000..26df48be71
--- /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 394d56587b..62691e8cde 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)
@@ -309,6 +320,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? {
val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value)
val fullRoom = cachedRoomListItem?.fullRoomWithTimeline(filter = eventFilters)
@@ -358,14 +385,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
}
@@ -414,6 +438,18 @@ class RustMatrixClient(
runCatching { client.removeAvatar() }
}
+ override suspend fun joinRoom(roomId: RoomId): Result = 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
@@ -426,6 +462,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 17ea8ee444..bf5c4c601f 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 0000000000..876a58d3a5
--- /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 0000000000..28e82f4e40
--- /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> =
+ callbackFlow {
+ val listener = object : RoomDirectorySearchEntriesListener {
+ override fun onUpdate(roomEntriesUpdate: List) {
+ 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 0000000000..aff631d6b4
--- /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>,
+ private val coroutineContext: CoroutineContext,
+ private val roomDescriptionMapper: RoomDescriptionMapper,
+) {
+ private val mutex = Mutex()
+
+ suspend fun postUpdates(updates: List) {
+ updateRoomDescriptions {
+ Timber.v("Update room descriptions from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
+ updates.forEach { update ->
+ applyUpdate(update)
+ }
+ }
+ }
+
+ private fun MutableList.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.() -> 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 0000000000..9b444d5ce5
--- /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>(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 {
+ return execute {
+ inner.search(filter = filter, batchSize = batchSize.toUInt())
+ }
+ }
+
+ override suspend fun loadMore(): Result {
+ return execute {
+ inner.nextPage()
+ }
+ }
+
+ private suspend fun execute(action: suspend () -> Unit): Result {
+ 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 =
+ 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 0000000000..2939001b21
--- /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/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 d7c3f71e64..247bc4bbe9 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 = Result.success(null),
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
@@ -91,6 +94,9 @@ class FakeMatrixClient(
private var setDisplayNameResult: Result = Result.success(Unit)
private var uploadAvatarResult: Result = Result.success(Unit)
private var removeAvatarResult: Result = Result.success(Unit)
+ var joinRoomLambda: suspend (RoomId) -> Result = {
+ 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 = 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 0000000000..b01501d328
--- /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 = emptyFlow(),
+ val filterLambda: (String?, Int) -> Result = { _, _ -> Result.success(Unit) },
+ val loadMoreLambda: () -> Result = { Result.success(Unit) }
+) : RoomDirectoryList {
+ override suspend fun filter(filter: String?, batchSize: Int) = filterLambda(filter, batchSize)
+
+ override suspend fun loadMore(): Result = 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 0000000000..68926b9deb
--- /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 0000000000..6e96ca4452
--- /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 1d237979e6..4374d77e52 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/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml
index f163de2540..70641271b8 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 @@
"Не ўдалося выбраць носьбіт, паўтарыце спробу."
"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."
"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."
- "Памылка загрузкі"
- "Каталог пакояў"
"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."
"Не ўдалося атрымаць інфармацыю пра карыстальніка"
"Заблакіраваць"
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 6975c64660..9dde757dc1 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 @@
"Výběr média se nezdařil, zkuste to prosím znovu."
"Nahrání média se nezdařilo, zkuste to prosím znovu."
"Nahrání média se nezdařilo, zkuste to prosím znovu."
- "Načítání se nezdařilo"
- "Adresář místností"
"Nahrání média se nezdařilo, zkuste to prosím znovu."
"Nepodařilo se načíst údaje o uživateli"
"Zablokovat"
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 83b3dcac0a..8a3e035833 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 @@
"Medienauswahl fehlgeschlagen, bitte versuche es erneut."
"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."
"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."
- "Fehler beim Laden"
- "Raumverzeichnis"
"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."
"Benutzerdetails konnten nicht abgerufen werden"
"Blockieren"
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 219b8501c5..e3b6e3d0d9 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 @@
"Échec de la sélection du média, veuillez réessayer."
"Échec du traitement des médias à télécharger, veuillez réessayer."
"Échec du téléchargement du média, veuillez réessayer."
- "Échec du chargement"
- "Annuaire des salons"
"Échec du traitement des médias à télécharger, veuillez réessayer."
"Impossible de récupérer les détails de l’utilisateur"
"Bloquer"
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 01a23b4c24..7758a05eeb 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 @@
"Не удалось выбрать носитель, попробуйте еще раз."
"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."
"Не удалось загрузить медиафайлы, попробуйте еще раз."
- "Сбой загрузки"
- "Каталог комнат"
"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."
"Не удалось получить данные о пользователе"
"Заблокировать"
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 9c1df72eaf..f49201bda1 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 @@
"Nepodarilo sa vybrať médium, skúste to prosím znova."
"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."
"Nepodarilo sa nahrať médiá, skúste to prosím znova."
- "Načítanie zlyhalo"
- "Adresár miestností"
"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."
"Nepodarilo sa získať údaje o používateľovi"
"Zablokovať"
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 542ff641aa..a97f92401a 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 @@
"Не вдалося вибрати медіафайл, спробуйте ще раз."
"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."
"Не вдалося завантажити медіафайл, спробуйте ще раз."
- "Не вдалося завантажити"
- "Каталог кімнат"
"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."
"Не вдалося отримати дані користувача"
"Заблокувати"
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 97ddec4b3b..1bc20364ce 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 @@
"Failed selecting media, please try again."
"Failed processing media to upload, please try again."
"Failed uploading media, please try again."
- "Failed loading"
- "Room directory"
"Failed processing media to upload, please try again."
"Could not retrieve user details"
"Block"
@@ -274,4 +272,55 @@
"Version: %1$s (%2$s)"
"en"
"en"
+ "Troubleshoot"
+ "Troubleshoot notifications"
+ "Run tests"
+ "Run tests again"
+ "Some tests failed. Please check the details."
+ "Run the tests to detect any issue in your configuration that may make notifications not behave as expected."
+ "Attempt to fix"
+ "All tests passed successfully."
+ "Troubleshoot notifications"
+ "Some tests require your attention. Please check the details."
+ "Check that the application can show notifications."
+ "Check permissions"
+ "Get the name of the current provider."
+ "No push providers selected."
+ "Current push provider: %1$s."
+ "Current push provider"
+ "Ensure that the application has at least one push provider."
+ "No push providers found."
+
+ - "Found %1$d push provider: %2$s"
+ - "Found %1$d push providers: %2$s"
+
+ "Detect push providers"
+ "Check that the application can display notification."
+ "The notification has not been clicked."
+ "Cannot display the notification."
+ "The notification has been clicked!"
+ "Display notification"
+ "Please click on the notification to continue the test."
+ "Ensure that Firebase is available."
+ "Firebase is not available."
+ "Firebase is available."
+ "Check Firebase"
+ "Ensure that Firebase token is available."
+ "Firebase token is not known."
+ "Firebase token: %1$s."
+ "Check Firebase token"
+ "Ensure that the application is receiving push."
+ "Error: pusher has rejected the request."
+ "Error: %1$s."
+ "Error, cannot test push."
+ "Error, timeout waiting for push."
+ "Push loop back took %1$d ms."
+ "Test Push loop back"
+ "Ensure that UnifiedPush distributors are available."
+ "No push distributors found."
+
+ - "%1$d distributor found: %2$s."
+ - "%1$d distributors found: %2$s."
+
+ "Check UnifiedPush"
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 e651e8f968..827038785c 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/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 0000000000..2735827134
--- /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 Presenter.test(
+ timeout: Duration? = null,
+ name: String? = null,
+ validate: suspend TurbineTestContext.() -> 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 0000000000..bd44675e52
--- /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 0000000000..8d0538f792
--- /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 0000000000..158c2f7cf5
--- /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 0000000000..c77504c186
--- /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 0000000000..0eb807db6d
--- /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 0000000000..ab56f08348
--- /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 0000000000..1ee85c5f13
--- /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 0000000000..80d7eb5d17
--- /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 0000000000..34fa07061a
--- /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 0000000000..cbf54de729
--- /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 5cc0279077..872a2f2274 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 0000000000..5cc0279077
--- /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 f7230cc774..845fd9fc0a 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 0000000000..f7230cc774
--- /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 0000000000..ec81aa14bc
--- /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 0000000000..27df947568
--- /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 0000000000..0f2ea2dda3
--- /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
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index b3ad636c86..aaa1f6734b 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_.*"
+ ]
}
]
}