First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.roomdirectory.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
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.androidutils)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.services.analytics.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.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
@ContributesBinding(AppScope::class)
class DefaultRoomDirectoryEntryPoint : RoomDirectoryEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: RoomDirectoryEntryPoint.Callback,
): Node {
return parentNode.createNode<RoomDirectoryNode>(buildContext, listOf(callback))
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root
sealed interface RoomDirectoryEvents {
data class Search(val query: String) : RoomDirectoryEvents
data object LoadMore : RoomDirectoryEvents
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class RoomDirectoryNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomDirectoryPresenter,
) : Node(buildContext, plugins = plugins) {
private val callback: RoomDirectoryEntryPoint.Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomDirectoryView(
state = state,
onResultClick = callback::navigateToRoom,
onBackClick = ::navigateUp,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 dev.zacsweers.metro.Inject
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.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
private const val SEARCH_BATCH_SIZE = 20
@Inject
class RoomDirectoryPresenter(
private val dispatchers: CoroutineDispatchers,
private val roomDirectoryService: RoomDirectoryService,
) : Presenter<RoomDirectoryState> {
@Composable
override fun present(): RoomDirectoryState {
var loadingMore by remember {
mutableStateOf(false)
}
var searchQuery by rememberSaveable {
mutableStateOf<String?>(null)
}
val coroutineScope = rememberCoroutineScope()
val roomDirectoryList = remember {
roomDirectoryService.createRoomDirectoryList(coroutineScope)
}
val listState by roomDirectoryList.collectState()
LaunchedEffect(searchQuery) {
if (searchQuery == null) return@LaunchedEffect
// cancel load more right away
loadingMore = false
// debounce search query
delay(300)
roomDirectoryList.filter(filter = searchQuery, batchSize = SEARCH_BATCH_SIZE, viaServerName = null)
}
LaunchedEffect(loadingMore) {
if (loadingMore) {
roomDirectoryList.loadMore()
loadingMore = false
}
}
fun handleEvent(event: RoomDirectoryEvents) {
when (event) {
RoomDirectoryEvents.LoadMore -> {
loadingMore = true
}
is RoomDirectoryEvents.Search -> {
searchQuery = event.query
}
}
}
return RoomDirectoryState(
query = searchQuery.orEmpty(),
roomDescriptions = listState.items,
displayLoadMoreIndicator = listState.hasMoreToLoad,
eventSink = ::handleEvent,
)
}
@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)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root
import io.element.android.features.roomdirectory.api.RoomDescription
import kotlinx.collections.immutable.ImmutableList
data class RoomDirectoryState(
val query: String,
val roomDescriptions: ImmutableList<RoomDescription>,
val displayLoadMoreIndicator: Boolean,
val eventSink: (RoomDirectoryEvents) -> Unit
) {
val displayEmptyState = roomDescriptions.isEmpty() && !displayLoadMoreIndicator
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class RoomDirectoryStateProvider : PreviewParameterProvider<RoomDirectoryState> {
override val values: Sequence<RoomDirectoryState>
get() = sequenceOf(
aRoomDirectoryState(),
aRoomDirectoryState(
query = "Element",
roomDescriptions = aRoomDescriptionList(),
),
aRoomDirectoryState(
query = "Element",
roomDescriptions = aRoomDescriptionList(),
displayLoadMoreIndicator = true,
),
)
}
fun aRoomDirectoryState(
query: String = "",
displayLoadMoreIndicator: Boolean = false,
roomDescriptions: ImmutableList<RoomDescription> = persistentListOf(),
eventSink: (RoomDirectoryEvents) -> Unit = {},
) = RoomDirectoryState(
query = query,
roomDescriptions = roomDescriptions,
displayLoadMoreIndicator = displayLoadMoreIndicator,
eventSink = eventSink,
)
fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
return persistentListOf(
RoomDescription(
roomId = RoomId("!exa:matrix.org"),
name = "Element X Android",
topic = "Element X is a secure, private and decentralized messenger.",
alias = RoomAlias("#element-x-android:matrix.org"),
avatarUrl = null,
joinRule = RoomDescription.JoinRule.PUBLIC,
numberOfMembers = 2765,
),
RoomDescription(
roomId = RoomId("!exi:matrix.org"),
name = "Element X iOS",
topic = "Element X is a secure, private and decentralized messenger.",
alias = RoomAlias("#element-x-ios:matrix.org"),
avatarUrl = null,
joinRule = RoomDescription.JoinRule.UNKNOWN,
numberOfMembers = 356,
)
)
}

View File

@@ -0,0 +1,281 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.FilledTextField
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.TopAppBar
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,
onResultClick: (RoomDescription) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
RoomDirectoryTopBar(onBackClick = onBackClick)
},
content = { padding ->
RoomDirectoryContent(
state = state,
onResultClick = onResultClick,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomDirectoryTopBar(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
BackButton(onClick = onBackClick)
},
titleStr = stringResource(id = R.string.screen_room_directory_search_title),
)
}
@Composable
private fun RoomDirectoryContent(
state: RoomDirectoryState,
onResultClick: (RoomDescription) -> 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,
onResultClick = onResultClick,
onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) },
)
}
}
@Composable
private fun RoomDirectoryRoomList(
roomDescriptions: ImmutableList<RoomDescription>,
displayLoadMoreIndicator: Boolean,
displayEmptyState: Boolean,
onResultClick: (RoomDescription) -> Unit,
onReachedLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
items(roomDescriptions) { roomDescription ->
RoomDirectoryRoomRow(
roomDescription = roomDescription,
onClick = {
onResultClick(roomDescription)
},
)
}
if (displayEmptyState) {
item {
Text(
text = stringResource(id = CommonStrings.common_no_results),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(16.dp)
)
}
}
if (displayLoadMoreIndicator) {
item {
LoadMoreIndicator(modifier = Modifier.fillMaxWidth())
LaunchedEffect(onReachedLoadMore) {
onReachedLoadMore()
}
}
}
}
}
@Composable
private fun LoadMoreIndicator(modifier: Modifier = Modifier) {
Box(
modifier = 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.textSecondary,
focusedPlaceholderColor = ElementTheme.colors.textSecondary,
focusedTextColor = ElementTheme.colors.textPrimary,
unfocusedTextColor = ElementTheme.colors.textPrimary,
focusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,
unfocusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,
),
) {
val focusManager = LocalFocusManager.current
FilledTextField(
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: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
top = 12.dp,
bottom = 12.dp,
start = 16.dp,
)
.height(IntrinsicSize.Min),
) {
Avatar(
avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem),
avatarType = AvatarType.Room(),
modifier = Modifier.align(Alignment.CenterVertically),
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
Text(
text = roomDescription.computedName,
maxLines = 1,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
)
Text(
text = roomDescription.computedDescription,
maxLines = 1,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview {
RoomDirectoryView(
state = state,
onResultClick = {},
onBackClick = {},
)
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root.model
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription as MatrixRoomDescription
fun MatrixRoomDescription.toFeatureModel(): RoomDescription {
return RoomDescription(
roomId = roomId,
name = name,
alias = alias,
topic = topic,
avatarUrl = avatarUrl,
numberOfMembers = numberOfMembers,
joinRule = when (joinRule) {
MatrixRoomDescription.JoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC
MatrixRoomDescription.JoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK
MatrixRoomDescription.JoinRule.RESTRICTED -> RoomDescription.JoinRule.RESTRICTED
MatrixRoomDescription.JoinRule.KNOCK_RESTRICTED -> RoomDescription.JoinRule.KNOCK_RESTRICTED
MatrixRoomDescription.JoinRule.INVITE -> RoomDescription.JoinRule.INVITE
MatrixRoomDescription.JoinRule.UNKNOWN -> RoomDescription.JoinRule.UNKNOWN
}
)
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root.model
import io.element.android.features.roomdirectory.api.RoomDescription
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class RoomDirectoryListState(
val hasMoreToLoad: Boolean,
val items: ImmutableList<RoomDescription>,
) {
companion object {
val Default = RoomDirectoryListState(
hasMoreToLoad = true,
items = persistentListOf()
)
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Памылка загрузкі"</string>
<string name="screen_room_directory_search_title">"Каталог пакояў"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Načítání se nezdařilo"</string>
<string name="screen_room_directory_search_title">"Adresář místností"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Wedi methu llwytho"</string>
<string name="screen_room_directory_search_title">"Cyfeiriadur ystafelloedd"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Indlæsning mislykkedes"</string>
<string name="screen_room_directory_search_title">"Register over rum"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Fehler beim Laden"</string>
<string name="screen_room_directory_search_title">"Chat-Verzeichnis"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Αποτυχία φόρτωσης"</string>
<string name="screen_room_directory_search_title">"Κατάλογος αιθουσών"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Carga fallida"</string>
<string name="screen_room_directory_search_title">"Directorio de salas"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Andmeid ei õnnestunud laadida"</string>
<string name="screen_room_directory_search_title">"Jututubade kataloog"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Ezin izan da kargatu"</string>
<string name="screen_room_directory_search_title">"Gelen direktorioa"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"شکست در بار کردن"</string>
<string name="screen_room_directory_search_title">"فهرست اتاق‌ها"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Lataus epäonnistui"</string>
<string name="screen_room_directory_search_title">"Huoneluettelo"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Échec du chargement"</string>
<string name="screen_room_directory_search_title">"Annuaire des salons"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Sikertelen betöltés"</string>
<string name="screen_room_directory_search_title">"Szobakatalógus"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Gagal memuat"</string>
<string name="screen_room_directory_search_title">"Direktori ruangan"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Caricamento fallito"</string>
<string name="screen_room_directory_search_title">"Elenco delle stanze"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"ჩატვირთვა წარუმატებელია"</string>
<string name="screen_room_directory_search_title">"ოთახის კატალოგი"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"로드에 실패했습니다"</string>
<string name="screen_room_directory_search_title">"방 디렉토리"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Kunne ikke laste inn"</string>
<string name="screen_room_directory_search_title">"Romkatalog"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Laden mislukt"</string>
<string name="screen_room_directory_search_title">"Kamergids"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Błąd wczytywania"</string>
<string name="screen_room_directory_search_title">"Katalog pokoi"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Falha no carregamento"</string>
<string name="screen_room_directory_search_title">"Diretório de salas"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Falha ao carregar"</string>
<string name="screen_room_directory_search_title">"Diretório de salas"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Încărcare eșuată"</string>
<string name="screen_room_directory_search_title">"Director de camere"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Сбой загрузки"</string>
<string name="screen_room_directory_search_title">"Каталог комнат"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Načítanie zlyhalo"</string>
<string name="screen_room_directory_search_title">"Adresár miestností"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Misslyckades att ladda"</string>
<string name="screen_room_directory_search_title">"Rumskatalog"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Yükleme başarısız"</string>
<string name="screen_room_directory_search_title">"Oda dizini"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Не вдалося завантажити"</string>
<string name="screen_room_directory_search_title">"Каталог кімнат"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"لادنا ناکام"</string>
<string name="screen_room_directory_search_title">"کمرے کا راہنامچہ"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Yuklab bolmadi"</string>
<string name="screen_room_directory_search_title">"Xona katalogi"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"無法載入"</string>
<string name="screen_room_directory_search_title">"聊天室目錄"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"加载失败"</string>
<string name="screen_room_directory_search_title">"聊天室目录"</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Failed loading"</string>
<string name="screen_room_directory_search_title">"Room directory"</string>
</resources>

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomdirectory.impl.root.RoomDirectoryNode
import io.element.android.features.roomdirectory.impl.root.createRoomDirectoryPresenter
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultRoomDirectoryEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultRoomDirectoryEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
RoomDirectoryNode(
buildContext = buildContext,
plugins = plugins,
presenter = createRoomDirectoryPresenter(),
)
}
val callback = object : RoomDirectoryEntryPoint.Callback {
override fun navigateToRoom(roomDescription: RoomDescription) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result).isInstanceOf(RoomDirectoryNode::class.java)
assertThat(result.plugins).contains(callback)
}
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdirectory.impl.root
import com.google.common.truth.Truth.assertThat
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.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.roomDescriptions).isEmpty()
assertThat(initialState.displayLoadMoreIndicator).isTrue()
}
}
@Test
fun `present - room directory list emits empty state`() = runTest {
val directoryListStateFlow = MutableSharedFlow<RoomDirectoryList.SearchResult>(replay = 1)
val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow)
val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
presenter.test {
skipItems(1)
directoryListStateFlow.emit(
RoomDirectoryList.SearchResult(false, emptyList())
)
awaitItem().also { state ->
assertThat(state.displayEmptyState).isTrue()
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - room directory list emits non-empty state`() = runTest {
val directoryListStateFlow = MutableSharedFlow<RoomDirectoryList.SearchResult>(replay = 1)
val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow)
val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
presenter.test {
skipItems(1)
directoryListStateFlow.emit(
RoomDirectoryList.SearchResult(
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, _: String? ->
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(), value(null))
}
@Test
fun `present - emit load more event`() = runTest {
val loadMoreLambda = lambdaRecorder<Result<Unit>> { 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()
}
}
internal fun TestScope.createRoomDirectoryPresenter(
roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(
createRoomDirectoryListFactory = { FakeRoomDirectoryList() }
),
): RoomDirectoryPresenter {
return RoomDirectoryPresenter(
dispatchers = testCoroutineDispatchers(),
roomDirectoryService = roomDirectoryService,
)
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.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.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomDirectoryViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `typing text in search field emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
rule.setRoomDirectoryView(
state = aRoomDirectoryState(
eventSink = eventsRecorder,
)
)
rule.onNodeWithTag(TestTags.searchTextField.value).performTextInput(
text = "Test"
)
eventsRecorder.assertSingle(RoomDirectoryEvents.Search("Test"))
}
@Test
fun `clicking on room item then onResultClick lambda is called once`() {
val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
val state = aRoomDirectoryState(
roomDescriptions = aRoomDescriptionList(),
eventSink = eventsRecorder,
)
val clickedRoom = state.roomDescriptions.first()
ensureCalledOnceWithParam(clickedRoom) { callback ->
rule.setRoomDirectoryView(
state = state,
onResultClick = callback,
)
rule.onNodeWithText(clickedRoom.computedName).performClick()
}
}
@Test
fun `composing load more indicator emits expected Event`() {
val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
val state = aRoomDirectoryState(
displayLoadMoreIndicator = true,
eventSink = eventsRecorder,
)
rule.setRoomDirectoryView(state = state)
eventsRecorder.assertSingle(RoomDirectoryEvents.LoadMore)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDirectoryView(
state: RoomDirectoryState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onResultClick: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
RoomDirectoryView(
state = state,
onResultClick = onResultClick,
onBackClick = onBackClick,
)
}
}