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
@@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.libraries.roomselect.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
@ContributesBinding(SessionScope::class)
class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: RoomSelectEntryPoint.Params,
callback: RoomSelectEntryPoint.Callback,
): Node {
return parentNode.createNode<RoomSelectNode>(
buildContext = buildContext,
plugins = listOf(
RoomSelectNode.Inputs(mode = params.mode),
callback,
)
)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvents {
data class SetSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvents
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : RoomSelectEvents
data class UpdateQuery(val query: String) : RoomSelectEvents
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ContributesNode(SessionScope::class)
@AssistedInject
class RoomSelectNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val mode: RoomSelectMode,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.mode)
private val callback: RoomSelectEntryPoint.Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomSelectView(
state = state,
onDismiss = callback::onCancel,
onSubmit = callback::onRoomSelected,
modifier = modifier
)
}
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@AssistedInject
class RoomSelectPresenter(
@Assisted private val mode: RoomSelectMode,
private val dataSource: RoomSelectSearchDataSource,
) : Presenter<RoomSelectState> {
@AssistedFactory
fun interface Factory {
fun create(mode: RoomSelectMode): RoomSelectPresenter
}
@Composable
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
dataSource.load()
}
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
}
val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val searchResults by remember<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> {
derivedStateOf {
when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Initial()
}
}
}
fun handleEvent(event: RoomSelectEvents) {
when (event) {
is RoomSelectEvents.SetSelectedRoom -> {
selectedRooms = persistentListOf(event.room)
// Restore for multi-selection
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
// selectedRooms = if (index >= 0) {
// selectedRooms.removeAt(index)
// } else {
// selectedRooms.add(event.room)
// }
}
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
is RoomSelectEvents.UpdateQuery -> searchQuery = event.query
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
}
}
return RoomSelectState(
mode = mode,
resultState = searchResults,
query = searchQuery,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,66 @@
/*
* 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.libraries.roomselect.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomList
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.loadAllIncrementally
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
private const val PAGE_SIZE = 30
/**
* DataSource for RoomSummaryDetails that can be filtered by a search query,
* and which only includes rooms the user has joined.
*/
@Inject
class RoomSelectSearchDataSource(
roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
) {
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All,
)
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.filteredSummaries
.map { roomSummaries ->
roomSummaries
.filter { it.info.currentUserMembership == CurrentUserMembership.JOINED }
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
.map { roomSummary -> roomSummary.toSelectRoomInfo() }
.toImmutableList()
}
.flowOn(coroutineDispatchers.computation)
suspend fun load() = coroutineScope {
roomList.loadAllIncrementally(this)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.all()
} else {
RoomListFilter.NormalizedMatchRoomName(searchQuery)
}
roomList.updateFilter(filter)
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
data class RoomSelectState(
val mode: RoomSelectMode,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val query: String,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val eventSink: (RoomSelectEvents) -> Unit
)
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
override val values: Sequence<RoomSelectState>
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(query = "Test", isSearchActive = true),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1),
),
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
),
)
}
private fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
) = RoomSelectState(
mode = mode,
resultState = resultState,
query = query,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = {}
)
private fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo(
roomId = RoomId("!room1:domain"),
name = "Room with name",
),
aSelectRoomInfo(
roomId = RoomId("!room2:domain"),
name = "Room with alias",
canonicalAlias = RoomAlias("#alias:example.org"),
),
aSelectRoomInfo(
roomId = RoomId("!room3:domain"),
),
)
@@ -0,0 +1,271 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Suppress("MultipleEmitters") // False positive
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomSelectView(
state: RoomSelectState,
onDismiss: () -> Unit,
onSubmit: (List<RoomId>) -> Unit,
modifier: Modifier = Modifier,
) {
@Suppress("UNUSED_PARAMETER")
fun onRoomRemoved(roomInfo: SelectRoomInfo) {
// TODO toggle selection when multi-selection is enabled
state.eventSink(RoomSelectEvents.RemoveSelectedRoom)
}
@Composable
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<SelectRoomInfo>) {
if (isForwarding) return
SelectedRooms(
selectedRooms = selectedRooms,
onRemoveRoom = ::onRoomRemoved,
modifier = Modifier.padding(vertical = 16.dp)
)
}
var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) {
if (state.isSearchActive) {
state.eventSink(RoomSelectEvents.ToggleSearchActive)
} else if (canHandleBack) {
canHandleBack = false
onDismiss()
}
}
BackHandler(
enabled = canHandleBack,
onBack = { onBackButton(state) }
)
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = when (state.mode) {
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message)
RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
},
navigationIcon = {
BackButton(
enabled = canHandleBack,
onClick = { onBackButton(state) }
)
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_send),
enabled = state.selectedRooms.isNotEmpty(),
onClick = { onSubmit(state.selectedRooms.map { it.roomId }) }
)
}
)
}
) { paddingValues ->
Column(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
) {
SearchBar(
modifier = Modifier.fillMaxWidth(),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.query,
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) },
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) },
resultState = state.resultState,
showBackButton = false,
) { summaries ->
LazyColumn {
item {
SelectedRoomsHelper(
// TODO state.isForwarding
isForwarding = false,
selectedRooms = state.selectedRooms
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary))
}
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
if (!state.isSearchActive) {
// TODO restore for multi-selection
// SelectedRoomsHelper(
// isForwarding = state.isForwarding,
// selectedRooms = state.selectedRooms
// )
Spacer(modifier = Modifier.height(20.dp))
if (state.resultState is SearchBarResultState.Results) {
LazyColumn {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary))
}
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
}
}
}
}
@Composable
private fun SelectedRooms(
selectedRooms: ImmutableList<SelectRoomInfo>,
onRemoveRoom: (SelectRoomInfo) -> Unit,
modifier: Modifier = Modifier,
) {
LazyRow(
modifier,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
items(selectedRooms, key = { it.roomId.value }) { selectRoomInfo ->
SelectedRoom(roomInfo = selectRoomInfo, onRemoveRoom = onRemoveRoom)
}
}
}
@Composable
private fun RoomSummaryView(
roomInfo: SelectRoomInfo,
isSelected: Boolean,
onSelection: (SelectRoomInfo) -> Unit,
) {
Row(
modifier = Modifier
.clickable { onSelection(roomInfo) }
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
.weight(1f)
) {
// Name
Text(
style = ElementTheme.typography.fontBodyLgRegular,
text = roomInfo.name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { roomInfo.name == null },
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Alias
roomInfo.canonicalAlias?.let { alias ->
Text(
text = alias.value,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
RadioButton(selected = isSelected, onClick = { onSelection(roomInfo) })
}
}
@PreviewsDayNight
@Composable
internal fun RoomSelectViewPreview(@PreviewParameter(RoomSelectStateProvider::class) state: RoomSelectState) = ElementPreview {
RoomSelectView(
state = state,
onDismiss = {},
onSubmit = {},
)
}
@@ -0,0 +1,56 @@
/*
* 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.libraries.roomselect.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.libraries.matrix.api.core.RoomId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
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 DefaultRoomSelectEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultRoomSelectEntryPoint()
val testMode = RoomSelectMode.Share
val parentNode = TestParentNode.create { buildContext, plugins ->
RoomSelectNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { mode ->
assertThat(mode).isEqualTo(testMode)
createRoomSelectPresenter(mode)
},
)
}
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
}
val params = RoomSelectEntryPoint.Params(testMode)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(RoomSelectNode::class.java)
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode))
assertThat(result.plugins).contains(callback)
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.roomselect.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class RoomSelectPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomSelectPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
}
}
@Test
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - update query`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(roomSummary))
}
val presenter = createRoomSelectPresenter(
roomListService = roomListService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val expectedRoomInfo = roomSummary.toSelectRoomInfo()
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
skipItems(1)
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained")
)
assertThat(awaitItem().query).isEqualTo("string not contained")
roomListService.postAllRooms(
emptyList()
)
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
}
}
@Test
fun `present - select and remove a room`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(roomSummary))
}
val presenter = createRoomSelectPresenter(
roomListService = roomListService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val roomInfo = roomSummary.toSelectRoomInfo()
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(roomInfo))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(roomInfo))
initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom)
assertThat(awaitItem().selectedRooms).isEmpty()
cancel()
}
}
}
internal fun TestScope.createRoomSelectPresenter(
mode: RoomSelectMode = RoomSelectMode.Forward,
roomListService: RoomListService = FakeRoomListService(),
) = RoomSelectPresenter(
mode = mode,
dataSource = RoomSelectSearchDataSource(
roomListService = roomListService,
coroutineDispatchers = testCoroutineDispatchers(),
),
)