refactor (start chat) : start splitting things (create room, invite people, start chat)

This commit is contained in:
ganfra 2025-07-11 18:18:17 +02:00 committed by Benoit Marty
parent ccb466bad8
commit 41cf1afce3
133 changed files with 2613 additions and 397 deletions

View file

@ -1,48 +0,0 @@
/*
* 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.createroom
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.features.createroom.impl.CreateRoomFlowNode.NavTarget
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
interface CreateRoomNavigator : Plugin {
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
fun onCreateNewRoom()
fun onShowJoinRoomByAddress()
fun onDismissJoinRoomByAddress()
fun onOpenRoomDirectory()
}
class DefaultCreateRoomNavigator(
private val backstack: BackStack<NavTarget>,
private val overlay: Overlay<NavTarget>,
private val openRoom: (RoomIdOrAlias, List<String>) -> Unit,
private val openRoomDirectory: () -> Unit,
) : CreateRoomNavigator {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) = openRoom(roomIdOrAlias, serverNames)
override fun onOpenRoomDirectory() = openRoomDirectory()
override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom)
}
override fun onShowJoinRoomByAddress() {
overlay.show(NavTarget.JoinByAddress)
}
override fun onDismissJoinRoomByAddress() {
overlay.hide()
}
}

View file

@ -1,83 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
import android.os.Parcelable
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 com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.features.createroom.impl.di.CreateRoomComponent
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class ConfigureRoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : DaggerComponentOwner,
BaseFlowNode<ConfigureRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
private val component by lazy {
parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build()
}
private val navigator = plugins<CreateRoomNavigator>().first()
override val daggerComponent: Any
get() = component
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object ConfigureRoom : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : AddPeopleNode.Callback {
override fun onContinue() {
backstack.push(NavTarget.ConfigureRoom)
}
}
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
}
NavTarget.ConfigureRoom -> {
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = listOf(navigator))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2023, 2024 New Vector 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.
@ -8,28 +8,19 @@
package io.element.android.features.createroom.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.DefaultCreateRoomNavigator
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.joinbyaddress.JoinRoomByAddressNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.OverlayView
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ -38,53 +29,24 @@ class CreateRoomFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
initialElement = NavTarget.ConfigureRoom,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object NewRoom : NavTarget
@Parcelize
data object JoinByAddress : NavTarget
}
private val navigator = DefaultCreateRoomNavigator(
backstack = backstack,
overlay = overlay,
openRoom = { roomIdOrAlias, viaServers ->
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomIdOrAlias, viaServers) }
},
openRoomDirectory = {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoomDirectory() }
}
)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.NewRoom -> {
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.JoinByAddress -> {
createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.ConfigureRoom -> createNode<ConfigureRoomNode>(buildContext)
is NavTarget.AddPeople -> createNode<AddPeopleNode>(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
BackstackView()
OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() })
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object ConfigureRoom : NavTarget
@Parcelize
data class AddPeople(val roomId: RoomId) : NavTarget
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2023, 2024 New Vector 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.
@ -9,27 +9,12 @@ package io.element.android.features.createroom.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.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreateRoomEntryPoint.NodeBuilder {
override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext, plugins)
}
}
class DefaultCreateRoomEntryPoint @Inject constructor(): CreateRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext)
}
}

View file

@ -1,51 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
import androidx.compose.runtime.MutableState
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction
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 io.element.android.libraries.matrix.api.room.StartDMResult
import io.element.android.libraries.matrix.api.room.startDM
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultStartDMAction @Inject constructor(
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
) : StartDMAction {
override suspend fun execute(
matrixUser: MatrixUser,
createIfDmDoesNotExist: Boolean,
actionState: MutableState<AsyncAction<RoomId>>,
) {
actionState.value = AsyncAction.Loading
when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) {
is StartDMResult.Success -> {
if (result.isNew) {
analyticsService.capture(CreatedRoom(isDM = true))
}
actionState.value = AsyncAction.Success(result.roomId)
}
is StartDMResult.Failure -> {
actionState.value = AsyncAction.Failure(result.throwable)
}
StartDMResult.DmDoesNotExist -> {
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2023, 2024 New Vector 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.
@ -7,39 +7,11 @@
package io.element.android.features.createroom.impl.addpeople
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.createroom.impl.di.CreateRoomScope
@ContributesNode(CreateRoomScope::class)
class AddPeopleNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AddPeoplePresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onContinue()
}
private fun onContinue() {
plugins<Callback>().forEach { it.onContinue() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AddPeopleView(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onNextClick = this::onContinue,
)
}
}
class AddPeopleNode(
buildContext: BuildContext,
plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)

View file

@ -1,37 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject
class AddPeoplePresenter @Inject constructor(
userListPresenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
dataStore: CreateRoomDataStore,
) : Presenter<UserListState> {
private val userListPresenter = userListPresenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Multiple,
),
userRepository,
dataStore.selectedUserListDataStore,
)
@Composable
override fun present(): UserListState {
return userListPresenter.present()
}
}

View file

@ -1,46 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.toImmutableList
open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState>
get() = sequenceOf(
aUserListState(),
aUserListState(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
aUserListState(
searchResults = SearchBarResultState.Results(
aMatrixUserList()
.mapIndexed { index, matrixUser ->
UserSearchResult(matrixUser, index % 2 == 0)
}
.toImmutableList()
),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
)
}

View file

@ -1,94 +0,0 @@
/*
* 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.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AddPeopleView(
state: UserListState,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
AddPeopleViewTopBar(
hasSelectedUsers = state.selectedUsers.isNotEmpty(),
onBackClick = {
if (state.isSearchActive) {
state.eventSink(UserListEvents.OnSearchActiveChanged(false))
} else {
onBackClick()
}
},
onNextClick = onNextClick,
)
}
) { padding ->
UserListView(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding),
state = state,
showBackButton = false,
onSelectUser = {},
onDeselectUser = {},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddPeopleViewTopBar(
hasSelectedUsers: Boolean,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
) {
TopAppBar(
titleStr = stringResource(id = R.string.screen_create_room_add_people_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
TextButton(
text = stringResource(id = textActionResId),
onClick = onNextClick,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun AddPeopleViewPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreview {
AddPeopleView(
state = state,
onBackClick = {},
onNextClick = {},
)
}

View file

@ -1,91 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable
fun SearchMultipleUsersResultItem(
searchResult: UserSearchResult,
isUserSelected: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val data = if (searchResult.isUnresolved) {
CheckableUserRowData.Unresolved(
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value,
)
} else {
CheckableUserRowData.Resolved(
name = searchResult.matrixUser.getBestName(),
subtext = if (searchResult.matrixUser.displayName.isNullOrEmpty()) null else searchResult.matrixUser.userId.value,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
)
}
CheckableUserRow(
checked = isUserSelected,
modifier = modifier,
data = data,
onCheckedChange = onCheckedChange,
)
}
@Preview
@Composable
internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview {
Column {
SearchMultipleUsersResultItem(
searchResult = UserSearchResult(
aMatrixUser(),
isUnresolved = false
),
isUserSelected = false,
onCheckedChange = {}
)
HorizontalDivider()
SearchMultipleUsersResultItem(
searchResult = UserSearchResult(
aMatrixUser(),
isUnresolved = false
),
isUserSelected = true,
onCheckedChange = {}
)
HorizontalDivider()
SearchMultipleUsersResultItem(
searchResult = UserSearchResult(
aMatrixUser(),
isUnresolved = true
),
isUserSelected = false,
onCheckedChange = {}
)
HorizontalDivider()
SearchMultipleUsersResultItem(
searchResult = UserSearchResult(
aMatrixUser(),
isUnresolved = true
),
isUserSelected = true,
onCheckedChange = {}
)
}
}

View file

@ -1,59 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable
fun SearchSingleUserResultItem(
searchResult: UserSearchResult,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (searchResult.isUnresolved) {
UnresolvedUserRow(
modifier = modifier.clickable(onClick = onClick),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value,
)
} else {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.UserListItem,
)
}
}
@Preview
@Composable
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview {
Column {
SearchSingleUserResultItem(
searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false),
onClick = {},
)
HorizontalDivider()
SearchSingleUserResultItem(
searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true),
onClick = {},
)
}
}

View file

@ -1,136 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<UserSearchResult>>,
showLoader: Boolean,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnable: Boolean,
onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onUserSelect: (MatrixUser) -> Unit,
onUserDeselect: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
showBackButton: Boolean = true,
placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
) {
val columnState = rememberLazyListState()
SearchBar(
query = query,
onQueryChange = onTextChange,
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
showBackButton = showBackButton,
contentPrefix = {
if (isMultiSelectionEnable && active && selectedUsers.isNotEmpty()) {
// We want the selected users to behave a bit like a top bar - when the list below is scrolled, the colour
// should change to indicate elevation.
val elevation = remember {
derivedStateOf {
if (columnState.canScrollBackward) {
4.dp
} else {
0.dp
}
}
}
val appBarContainerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation.value),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
)
SelectedUsersRowList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemove = onUserDeselect,
modifier = Modifier.background(appBarContainerColor)
)
}
},
contentSuffix = {
if (showLoader) {
AsyncLoading()
}
},
resultState = state,
resultHandler = { users ->
LazyColumn(state = columnState) {
if (isMultiSelectionEnable) {
itemsIndexed(users) { index, searchResult ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
searchResult = searchResult,
isUserSelected = selectedUsers.contains(searchResult.matrixUser),
onCheckedChange = { checked ->
if (checked) {
onUserSelect(searchResult.matrixUser)
} else {
onUserDeselect(searchResult.matrixUser)
}
}
)
if (index < users.lastIndex) {
HorizontalDivider()
}
}
} else {
itemsIndexed(users) { index, searchResult ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
searchResult = searchResult,
onClick = { onUserSelect(searchResult.matrixUser) }
)
if (index < users.lastIndex) {
HorizontalDivider()
}
}
}
}
},
)
}

View file

@ -1,126 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserListView(
state: UserListState,
onSelectUser: (MatrixUser) -> Unit,
onDeselectUser: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
showBackButton: Boolean = true,
) {
Column(
modifier = modifier,
) {
SearchUserBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
state = state.searchResults,
selectedUsers = state.selectedUsers,
active = state.isSearchActive,
showLoader = state.showSearchLoader,
isMultiSelectionEnable = state.isMultiSelectionEnabled,
showBackButton = showBackButton,
onActiveChange = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
onTextChange = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
onUserSelect = {
state.eventSink(UserListEvents.AddToSelection(it))
onSelectUser(it)
},
onUserDeselect = {
state.eventSink(UserListEvents.RemoveFromSelection(it))
onDeselectUser(it)
},
)
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
SelectedUsersRowList(
contentPadding = PaddingValues(16.dp),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemove = {
state.eventSink(UserListEvents.RemoveFromSelection(it))
onDeselectUser(it)
},
)
}
if (!state.isSearchActive && state.recentDirectRooms.isNotEmpty()) {
LazyColumn {
item {
ListSectionHeader(
title = stringResource(id = CommonStrings.common_suggestions),
hasDivider = false,
)
}
state.recentDirectRooms.forEachIndexed { index, recentDirectRoom ->
item {
val isSelected = state.selectedUsers.any {
recentDirectRoom.matrixUser.userId == it.userId
}
CheckableUserRow(
checked = isSelected,
onCheckedChange = {
if (isSelected) {
state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser))
onDeselectUser(recentDirectRoom.matrixUser)
} else {
state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser))
onSelectUser(recentDirectRoom.matrixUser)
}
},
data = CheckableUserRowData.Resolved(
avatarData = recentDirectRoom.matrixUser.getAvatarData(AvatarSize.UserListItem),
name = recentDirectRoom.matrixUser.getBestName(),
subtext = recentDirectRoom.matrixUser.userId.value,
),
)
if (index < state.recentDirectRooms.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun UserListViewPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreview {
UserListView(
state = state,
onSelectUser = {},
onDeselectUser = {},
)
}

View file

@ -16,7 +16,6 @@ sealed interface ConfigureRoomEvents {
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data class RemoveUserFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents

View file

@ -13,24 +13,20 @@ import com.bumble.appyx.core.lifecycle.subscribe
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 im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(CreateRoomScope::class)
@ContributesNode(SessionScope::class)
class ConfigureRoomNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ConfigureRoomPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<CreateRoomNavigator>().first()
init {
lifecycle.subscribe(
@ -48,7 +44,7 @@ class ConfigureRoomNode @AssistedInject constructor(
modifier = modifier,
onBackClick = this::navigateUp,
onCreateRoomSuccess = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
)
}

View file

@ -18,8 +18,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -50,7 +48,7 @@ import javax.inject.Inject
import kotlin.jvm.optionals.getOrDefault
class ConfigureRoomPresenter @Inject constructor(
private val dataStore: CreateRoomDataStore,
private val dataStore: CreateRoomConfigStore,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
@ -66,7 +64,7 @@ class ConfigureRoomPresenter @Inject constructor(
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
@ -121,7 +119,6 @@ class ConfigureRoomPresenter @Inject constructor(
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)

View file

@ -7,7 +7,6 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.media.AvatarAction

View file

@ -8,7 +8,6 @@
package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUserList

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
@ -58,7 +57,6 @@ 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.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.matrix.ui.room.address.RoomAddressField
import io.element.android.libraries.permissions.api.PermissionsView
@ -112,16 +110,6 @@ fun ConfigureRoomView(
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (state.config.invites.isNotEmpty()) {
SelectedUsersRowList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemove = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(it))
},
)
}
RoomVisibilityOptions(
selected = when (state.config.roomVisibility) {
is RoomVisibilityState.Private -> RoomVisibilityItem.Private

View file

@ -5,10 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -5,45 +5,30 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomAccess
import io.element.android.features.createroom.impl.configureroom.RoomAccessItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import java.io.File
import javax.inject.Inject
@SingleIn(CreateRoomScope::class)
class CreateRoomDataStore @Inject constructor(
val selectedUserListDataStore: UserListDataStore,
class CreateRoomConfigStore @Inject constructor(
private val roomAliasHelper: RoomAliasHelper,
) {
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
private var cachedAvatarUri: Uri? = null
set(value) {
field?.path?.let { File(it) }?.safeDelete()
field = value
}
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers,
createRoomConfigFlow,
) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList())
}
fun getCreateRoomConfigFlow(): StateFlow<CreateRoomConfig> = createRoomConfigFlow
fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config ->

View file

@ -1,28 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.di
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.MergeSubcomponent
import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
@SingleIn(CreateRoomScope::class)
@MergeSubcomponent(CreateRoomScope::class)
interface CreateRoomComponent : NodeFactoriesBindings {
@MergeSubcomponent.Builder
interface Builder {
fun build(): CreateRoomComponent
}
@ContributesTo(SessionScope::class)
interface ParentBindings {
fun createRoomComponentBuilder(): Builder
}
}

View file

@ -1,10 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.di
abstract class CreateRoomScope private constructor()

View file

@ -1,14 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
sealed interface JoinRoomByAddressEvents {
data object Dismiss : JoinRoomByAddressEvents
data object Continue : JoinRoomByAddressEvents
data class UpdateAddress(val address: String) : JoinRoomByAddressEvents
}

View file

@ -1,39 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
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.createroom.CreateRoomNavigator
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class JoinRoomByAddressNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: JoinRoomByAddressPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<CreateRoomNavigator>().first()
private val presenter = presenterFactory.create(navigator)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
JoinRoomByAddressView(
state = state,
modifier = modifier
)
}
}

View file

@ -1,138 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
private const val ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS = 10
class JoinRoomByAddressPresenter @AssistedInject constructor(
@Assisted private val navigator: CreateRoomNavigator,
private val client: MatrixClient,
private val roomAliasHelper: RoomAliasHelper,
) : Presenter<JoinRoomByAddressState> {
@AssistedFactory
interface Factory {
fun create(navigator: CreateRoomNavigator): JoinRoomByAddressPresenter
}
@Composable
override fun present(): JoinRoomByAddressState {
var address by remember { mutableStateOf("") }
var internalAddressState by remember { mutableStateOf<RoomAddressState>(RoomAddressState.Unknown) }
var validateAddress: Boolean by remember { mutableStateOf(false) }
fun handleEvents(event: JoinRoomByAddressEvents) {
when (event) {
JoinRoomByAddressEvents.Continue -> {
when (val currentState = internalAddressState) {
is RoomAddressState.RoomFound -> onRoomFound(currentState)
else -> validateAddress = true
}
}
JoinRoomByAddressEvents.Dismiss -> navigator.onDismissJoinRoomByAddress()
is JoinRoomByAddressEvents.UpdateAddress -> {
validateAddress = false
address = event.address.trim()
}
}
}
RoomAddressStateEffect(
fullAddress = address,
onRoomAddressStateChange = { addressState ->
internalAddressState = addressState
if (addressState is RoomAddressState.RoomFound && validateAddress) {
onRoomFound(addressState)
}
}
)
val addressState by remember {
derivedStateOf {
// We only want to show the "RoomFound" state as long as the user didn't validate the address.
if (validateAddress || internalAddressState is RoomAddressState.RoomFound) {
internalAddressState
} else {
RoomAddressState.Unknown
}
}
}
return JoinRoomByAddressState(
address = address,
addressState = addressState,
eventSink = ::handleEvents
)
}
private fun onRoomFound(state: RoomAddressState.RoomFound) {
navigator.onDismissJoinRoomByAddress()
navigator.onOpenRoom(
roomIdOrAlias = state.resolved.roomId.toRoomIdOrAlias(),
serverNames = state.resolved.servers
)
}
@Composable
private fun RoomAddressStateEffect(
fullAddress: String,
onRoomAddressStateChange: (RoomAddressState) -> Unit,
) {
val onChange by rememberUpdatedState(onRoomAddressStateChange)
LaunchedEffect(fullAddress) {
// Whenever the address changes, reset the state to unknown
onChange(RoomAddressState.Unknown)
// debounce the room address resolution
delay(300)
val roomAlias = tryOrNull { RoomAlias(fullAddress) }
if (roomAlias != null && roomAliasHelper.isRoomAliasValid(roomAlias)) {
onChange(RoomAddressState.Resolving)
onChange(client.resolveRoomAddress(roomAlias))
} else {
onChange(RoomAddressState.Invalid)
}
}
}
private suspend fun MatrixClient.resolveRoomAddress(roomAlias: RoomAlias): RoomAddressState {
return withTimeoutOrNull(ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) {
resolveRoomAlias(roomAlias)
.fold(
onSuccess = { resolved ->
if (resolved.isPresent) {
RoomAddressState.RoomFound(resolved.get())
} else {
RoomAddressState.RoomNotFound
}
},
onFailure = { _ -> RoomAddressState.RoomNotFound }
)
} ?: RoomAddressState.RoomNotFound
}
}

View file

@ -1,26 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
data class JoinRoomByAddressState(
val address: String,
val addressState: RoomAddressState,
val eventSink: (JoinRoomByAddressEvents) -> Unit
)
@Immutable
sealed interface RoomAddressState {
data object Unknown : RoomAddressState
data object Invalid : RoomAddressState
data object Resolving : RoomAddressState
data object RoomNotFound : RoomAddressState
data class RoomFound(val resolved: ResolvedRoomAlias) : RoomAddressState
}

View file

@ -1,37 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
open class JoinRoomByAddressStateProvider : PreviewParameterProvider<JoinRoomByAddressState> {
override val values: Sequence<JoinRoomByAddressState>
get() = sequenceOf(
aJoinRoomByAddressState(),
aJoinRoomByAddressState(address = "#room-"),
aJoinRoomByAddressState(address = "#room-", addressState = RoomAddressState.Invalid),
aJoinRoomByAddressState(address = "#room-name:matrix.org", addressState = RoomAddressState.Resolving),
aJoinRoomByAddressState(address = "#room-name-none:matrix.org", addressState = RoomAddressState.RoomNotFound),
aJoinRoomByAddressState(
address = "#room-name:matrix.org",
addressState = RoomAddressState.RoomFound(ResolvedRoomAlias(RoomId("!aRoom:id"), emptyList())),
),
)
}
fun aJoinRoomByAddressState(
address: String = "",
addressState: RoomAddressState = RoomAddressState.Unknown,
eventSink: (JoinRoomByAddressEvents) -> Unit = {},
) = JoinRoomByAddressState(
address = address,
addressState = addressState,
eventSink = eventSink
)

View file

@ -1,134 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
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.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinRoomByAddressView(
state: JoinRoomByAddressState,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
modifier = modifier,
sheetState = sheetState,
onDismissRequest = {
state.eventSink(JoinRoomByAddressEvents.Dismiss)
},
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RoomAddressField(
address = state.address,
addressState = state.addressState,
requestFocus = sheetState.isVisible,
onAddressChange = {
state.eventSink(JoinRoomByAddressEvents.UpdateAddress(it))
},
onContinue = {
state.eventSink(JoinRoomByAddressEvents.Continue)
},
)
Spacer(modifier = Modifier.height(24.dp))
Button(
text = stringResource(CommonStrings.action_continue),
modifier = Modifier.fillMaxWidth(),
showProgress = state.addressState is RoomAddressState.Resolving,
onClick = {
state.eventSink(JoinRoomByAddressEvents.Continue)
}
)
}
}
}
@Composable
private fun RoomAddressField(
address: String,
addressState: RoomAddressState,
requestFocus: Boolean,
onAddressChange: (String) -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
if (requestFocus) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
TextField(
modifier = modifier.focusRequester(focusRequester),
value = address,
label = stringResource(R.string.screen_start_chat_join_room_by_address_action),
placeholder = stringResource(R.string.screen_start_chat_join_room_by_address_placeholder),
supportingText = when (addressState) {
RoomAddressState.Invalid -> stringResource(R.string.screen_start_chat_join_room_by_address_invalid_address)
is RoomAddressState.RoomFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_found)
RoomAddressState.RoomNotFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_not_found)
RoomAddressState.Unknown, RoomAddressState.Resolving -> stringResource(R.string.screen_start_chat_join_room_by_address_supporting_text)
},
validity = when (addressState) {
RoomAddressState.Unknown, RoomAddressState.Resolving -> TextFieldValidity.None
RoomAddressState.Invalid, RoomAddressState.RoomNotFound -> TextFieldValidity.Invalid
is RoomAddressState.RoomFound -> TextFieldValidity.Valid
},
onValueChange = onAddressChange,
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = { onContinue() }
)
)
}
@PreviewsDayNight
@Composable
internal fun JoinRoomByAddressViewPreview(
@PreviewParameter(JoinRoomByAddressStateProvider::class) state: JoinRoomByAddressState
) = ElementPreview {
JoinRoomByAddressView(state = state)
}

View file

@ -1,15 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface CreateRoomRootEvents {
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
data object CancelStartDM : CreateRoomRootEvents
}

View file

@ -1,66 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
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 im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: CreateRoomRootPresenter,
private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<CreateRoomNavigator>().first()
init {
lifecycle.subscribe(
onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) }
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
CreateRoomRootView(
state = state,
modifier = modifier,
onCloseClick = this::navigateUp,
onNewRoomClick = navigator::onCreateNewRoom,
onOpenDM = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
onInviteFriendsClick = { invitePeople(activity) },
onRoomDirectorySearchClick = navigator::onOpenRoomDirectory
)
}
private fun invitePeople(activity: Activity) {
inviteFriendsUseCase.execute(activity)
}
}

View file

@ -1,80 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable
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 io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
class CreateRoomRootPresenter @Inject constructor(
presenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
private val startDMAction: StartDMAction,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
) : Presenter<CreateRoomRootState> {
private val presenter = presenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
userListDataStore,
)
@Composable
override fun present(): CreateRoomRootState {
val userListState = presenter.present()
val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isRoomDirectorySearchEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
}.collectAsState(initial = false)
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
startDMAction.execute(
matrixUser = event.matrixUser,
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
actionState = startDmActionState,
)
}
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
}
}
return CreateRoomRootState(
applicationName = buildMeta.applicationName,
userListState = userListState,
startDmAction = startDmActionState.value,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvents,
)
}
}

View file

@ -1,20 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
data class CreateRoomRootState(
val applicationName: String,
val userListState: UserListState,
val startDmAction: AsyncAction<RoomId>,
val isRoomDirectorySearchEnabled: Boolean,
val eventSink: (CreateRoomRootEvents) -> Unit,
)

View file

@ -1,74 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState>
get() = sequenceOf(
aCreateRoomRootState(),
aCreateRoomRootState(
startDmAction = AsyncAction.Loading,
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
}
),
aCreateRoomRootState(
startDmAction = AsyncAction.Failure(RuntimeException("error")),
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
}
),
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = aRecentDirectRoomList()
)
),
aCreateRoomRootState(
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
),
aCreateRoomRootState(
isRoomDirectorySearchEnabled = true,
),
)
}
fun aCreateRoomRootState(
applicationName: String = "Element X Preview",
userListState: UserListState = aUserListState(),
startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
isRoomDirectorySearchEnabled: Boolean = false,
eventSink: (CreateRoomRootEvents) -> Unit = {},
) = CreateRoomRootState(
applicationName = applicationName,
userListState = userListState,
startDmAction = startDmAction,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = eventSink,
)

View file

@ -1,253 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun CreateRoomRootView(
state: CreateRoomRootState,
onCloseClick: () -> Unit,
onNewRoomClick: () -> Unit,
onOpenDM: (RoomId) -> Unit,
onInviteFriendsClick: () -> Unit,
onJoinByAddressClick: () -> Unit,
onRoomDirectorySearchClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
if (!state.userListState.isSearchActive) {
CreateRoomRootViewTopBar(onCloseClick = onCloseClick)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
UserListView(
modifier = Modifier.fillMaxWidth(),
// Do not render suggestions in this case, the suggestion will be rendered
// by CreateRoomActionButtonsList
state = state.userListState.copy(
recentDirectRooms = persistentListOf(),
),
onSelectUser = {
state.eventSink(CreateRoomRootEvents.StartDM(it))
},
onDeselectUser = { },
)
if (!state.userListState.isSearchActive) {
CreateRoomActionButtonsList(
state = state,
onNewRoomClick = onNewRoomClick,
onInvitePeopleClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinByAddressClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
onDmClick = onOpenDM,
)
}
}
}
AsyncActionView(
async = state.startDmAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(CommonStrings.common_starting_chat),
)
},
onSuccess = { onOpenDM(it) },
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
onRetry = {
state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
confirmationDialog = { data ->
if (data is ConfirmingStartDmWithMatrixUser) {
CreateDmConfirmationBottomSheet(
matrixUser = data.matrixUser,
onSendInvite = {
state.eventSink(CreateRoomRootEvents.StartDM(data.matrixUser))
},
onDismiss = {
state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
)
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateRoomRootViewTopBar(
onCloseClick: () -> Unit,
) {
TopAppBar(
titleStr = stringResource(id = CommonStrings.action_start_chat),
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onCloseClick,
)
}
)
}
@Composable
private fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
onNewRoomClick: () -> Unit,
onInvitePeopleClick: () -> Unit,
onJoinByAddressClick: () -> Unit,
onRoomDirectorySearchClick: () -> Unit,
onDmClick: (RoomId) -> Unit,
) {
LazyColumn {
item {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_plus,
text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClick,
)
}
if (state.isRoomDirectorySearchEnabled) {
item {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_list_bulleted,
text = stringResource(id = R.string.screen_room_directory_search_title),
onClick = onRoomDirectorySearchClick,
)
}
}
item {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_share_android,
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClick,
)
}
item {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_compound_room,
text = stringResource(R.string.screen_start_chat_join_room_by_address_action),
onClick = onJoinByAddressClick,
)
}
if (state.userListState.recentDirectRooms.isNotEmpty()) {
item {
ListSectionHeader(
title = stringResource(id = CommonStrings.common_suggestions),
hasDivider = false,
)
}
state.userListState.recentDirectRooms.forEach { recentDirectRoom ->
item {
MatrixUserRow(
modifier = Modifier.clickable(
onClick = {
onDmClick(recentDirectRoom.roomId)
}
),
matrixUser = recentDirectRoom.matrixUser,
)
}
}
}
}
}
@Composable
private fun CreateRoomActionButton(
@DrawableRes iconRes: Int,
text: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onClick() }
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(24.dp),
tint = ElementTheme.colors.iconSecondary,
resourceId = iconRes,
contentDescription = null,
)
Text(
text = text,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
}
@PreviewsDayNight
@Composable
internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
ElementPreview {
CreateRoomRootView(
state = state,
onCloseClick = {},
onNewRoomClick = {},
onOpenDM = {},
onJoinByAddressClick = {},
onInviteFriendsClick = {},
onRoomDirectorySearchClick = {},
)
}

View file

@ -1,95 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs,
@Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
) : UserListPresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
interface DefaultUserListFactory : UserListPresenter.Factory {
override fun create(
args: UserListPresenterArgs,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): DefaultUserListPresenter
}
@Composable
override fun present(): UserListState {
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
LaunchedEffect(Unit) {
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.Initial())
}
var showSearchLoader by remember { mutableStateOf(false) }
LaunchedEffect(searchQuery) {
searchResults = SearchBarResultState.Initial()
showSearchLoader = false
userRepository.search(searchQuery).onEach { state ->
showSearchLoader = state.isSearching
searchResults = when {
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial()
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Results(state.results.toImmutableList())
}
}.launchIn(this)
}
return UserListState(
searchQuery = searchQuery,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
selectionMode = args.selectionMode,
recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = { event ->
when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
}
},
)
}
}

View file

@ -1,29 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class UserListDataStore @Inject constructor() {
private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
if (!_selectedUsers.value.contains(user)) {
_selectedUsers.tryEmit(_selectedUsers.value.plus(user))
}
}
fun removeUserFromSelection(user: MatrixUser) {
_selectedUsers.tryEmit(_selectedUsers.value.minus(user))
}
val selectedUsers = _selectedUsers.asStateFlow()
}

View file

@ -1,17 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface UserListEvents {
data class UpdateSearchQuery(val query: String) : UserListEvents
data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents
data class OnSearchActiveChanged(val active: Boolean) : UserListEvents
}

View file

@ -1,21 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
interface UserListPresenter : Presenter<UserListState> {
interface Factory {
fun create(
args: UserListPresenterArgs,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): UserListPresenter
}
}

View file

@ -1,17 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
data class UserListPresenterArgs(
val selectionMode: SelectionMode,
)
enum class SelectionMode {
Single,
Multiple,
}

View file

@ -1,27 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
val showSearchLoader: Boolean,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
val recentDirectRooms: ImmutableList<RecentDirectRoom>,
val eventSink: (UserListEvents) -> Unit,
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
}

View file

@ -1,90 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
open class UserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState>
get() = sequenceOf(
aUserListState(),
aUserListState(
isSearchActive = false,
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
),
aUserListState(isSearchActive = true),
aUserListState(isSearchActive = true, searchQuery = "someone"),
aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aUserListState(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
),
aUserListState(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
),
aUserListState(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound()
),
aUserListState(
isSearchActive = true,
searchQuery = "someone",
selectionMode = SelectionMode.Single,
),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
)
}
fun aUserListState(
searchQuery: String = "",
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> = SearchBarResultState.Initial(),
selectedUsers: List<MatrixUser> = emptyList(),
showSearchLoader: Boolean = false,
selectionMode: SelectionMode = SelectionMode.Single,
recentDirectRooms: List<RecentDirectRoom> = emptyList(),
eventSink: (UserListEvents) -> Unit = {},
) = UserListState(
searchQuery = searchQuery,
isSearchActive = isSearchActive,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
showSearchLoader = showSearchLoader,
selectionMode = selectionMode,
recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = eventSink
)
fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList()
fun aRecentDirectRoomList(
count: Int = 5
): List<RecentDirectRoom> = aMatrixUserList()
.take(count)
.map {
RecentDirectRoom(RoomId("!aRoom:id"), it)
}

View file

@ -1,105 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
import androidx.compose.runtime.mutableStateOf
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultStartDMActionTest {
@Test
fun `when dm is found, assert state is updated with given room id`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).isEmpty()
}
@Test
fun `when finding the dm fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(Result.failure(AN_EXCEPTION))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
assertThat(analyticsService.capturedEvents).isEmpty()
}
@Test
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
}
@Test
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
val matrixUser = aMatrixUser()
action.execute(matrixUser, false, state)
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
assertThat(analyticsService.capturedEvents).isEmpty()
}
@Test
fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.failure(AN_EXCEPTION))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
assertThat(analyticsService.capturedEvents).isEmpty()
}
private fun createStartDMAction(
matrixClient: MatrixClient = FakeMatrixClient(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
): DefaultStartDMAction {
return DefaultStartDMAction(
matrixClient = matrixClient,
analyticsService = analyticsService,
)
}
}

View file

@ -1,39 +0,0 @@
/*
* 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.createroom.impl
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
class FakeCreateRoomNavigator(
private val openRoomLambda: (roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) -> Unit = { _, _ -> },
private val createNewRoomLambda: () -> Unit = {},
private val showJoinRoomByAddressLambda: () -> Unit = {},
private val dismissJoinRoomByAddressLambda: () -> Unit = {},
private val openRoomDirectoryLambda: () -> Unit = {},
) : CreateRoomNavigator {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
openRoomLambda(roomIdOrAlias, serverNames)
}
override fun onCreateNewRoom() {
createNewRoomLambda()
}
override fun onShowJoinRoomByAddress() {
showJoinRoomByAddressLambda()
}
override fun onDismissJoinRoomByAddress() {
dismissJoinRoomByAddressLambda()
}
override fun onOpenRoomDirectory() {
openRoomDirectoryLambda()
}
}

View file

@ -1,50 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.addpeople
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.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AddPeoplePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private lateinit var presenter: AddPeoplePresenter
@Before
fun setup() {
presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
)
}
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// TODO This doesn't actually test anything...
val initialState = awaitItem()
assertThat(initialState)
}
}
}

View file

@ -1,89 +0,0 @@
/*
* 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.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AddPeopleViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onBackClick = it
)
rule.pressBack()
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
@Test
fun `clicking on back during search emits the expected Event`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
rule.setAddPeopleView(
aUserListState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertSingle(UserListEvents.OnSearchActiveChanged(false))
}
@Test
fun `clicking on skip invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onNextClick = it
)
rule.clickOn(CommonStrings.action_skip)
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddPeopleView(
state: UserListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onNextClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AddPeopleView(
state = state,
onBackClick = onBackClick,
onNextClick = onNextClick,
)
}
}

View file

@ -1,429 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import app.cash.turbine.TurbineTestContext
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.Optional
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
class ConfigureBaseRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Before
fun setup() {
mockkStatic(File::readBytes)
every { any<File>().readBytes() } returns byteArrayOf()
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `present - initial state`() = runTest {
val presenter = createConfigureRoomPresenter()
presenter.test {
val initialState = initialState()
assertThat(initialState.config).isEqualTo(CreateRoomConfig())
assertThat(initialState.config.roomName).isNull()
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
}
}
@Test
fun `present - create room button is enabled only if the required fields are completed`() = runTest {
val presenter = createConfigureRoomPresenter()
presenter.test {
val initialState = initialState()
var config = initialState.config
assertThat(initialState.isValid).isFalse()
// Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.isValid).isTrue()
// Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState = awaitItem()
config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.isValid).isFalse()
}
}
@Test
fun `present - state is updated when fields are changed`() = runTest {
val userListDataStore = UserListDataStore()
val pickerProvider = FakePickerProvider()
val permissionsPresenter = FakePermissionsPresenter()
val roomAliasHelper = FakeRoomAliasHelper()
val presenter = createConfigureRoomPresenter(
createRoomDataStore = CreateRoomDataStore(userListDataStore, roomAliasHelper),
pickerProvider = pickerProvider,
permissionsPresenter = permissionsPresenter,
)
presenter.test {
val initialState = initialState()
var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig)
// Select User
val selectedUser1 = aMatrixUser()
val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob")
userListDataStore.selectUser(selectedUser1)
skipItems(1)
userListDataStore.selectUser(selectedUser2)
var newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2))
assertThat(newState.config).isEqualTo(expectedConfig)
// Room name
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
newState = awaitItem()
expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(expectedConfig)
// Room topic
newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE))
newState = awaitItem()
expectedConfig = expectedConfig.copy(topic = A_MESSAGE)
assertThat(newState.config).isEqualTo(expectedConfig)
// Room avatar
// Pick avatar
pickerProvider.givenResult(null)
// From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
pickerProvider.givenResult(uriFromGallery)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig)
// From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
pickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue()
permissionsPresenter.setPermissionGranted()
newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
assertThat(newState.config).isEqualTo(expectedConfig)
// Do it again, no permission is requested
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
pickerProvider.givenResult(uriFromCamera2)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = null)
assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy
newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
newState = awaitItem()
expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
roomAccess = RoomAccess.Anyone,
)
)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove user
newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
assertThat(newState.config).isEqualTo(expectedConfig)
}
}
@Test
fun `present - trigger create room action`() = runTest {
val matrixClient = createMatrixClient()
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull())
}
}
@Test
fun `present - record analytics when creating room`() = runTest {
val matrixClient = createMatrixClient()
val analyticsService = FakeAnalyticsService()
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
skipItems(2)
val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isFalse()
}
}
@Test
fun `present - trigger create room with upload error and retry`() = runTest {
val matrixClient = createMatrixClient()
val analyticsService = FakeAnalyticsService()
val mediaPreProcessor = FakeMediaPreProcessor()
val createRoomDataStore = CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
val presenter = createConfigureRoomPresenter(
createRoomDataStore = createRoomDataStore,
mediaPreProcessor = mediaPreProcessor,
matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
skipItems(1)
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(analyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
}
}
@Test
fun `present - trigger retry and cancel actions`() = runTest {
val fakeMatrixClient = createMatrixClient()
val presenter = createConfigureRoomPresenter(
matrixClient = fakeMatrixClient
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.failure<RoomId>(AN_EXCEPTION)
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
// Create
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Retry
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterRetry = awaitItem()
assertThat(stateAfterRetry.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Cancel
stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - address is invalid when format is invalid`() = runTest {
val aliasHelper = FakeRoomAliasHelper(
isRoomAliasValidLambda = { false }
)
val presenter = createConfigureRoomPresenter(
roomAliasHelper = aliasHelper
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - address is not available when alias is not available`() = runTest {
val fakeMatrixClient = createMatrixClient(isAliasAvailable = false)
val presenter = createConfigureRoomPresenter(
matrixClient = fakeMatrixClient,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - address is valid when alias is available and format is valid`() = runTest {
val fakeMatrixClient = createMatrixClient(isAliasAvailable = true)
val presenter = createConfigureRoomPresenter(
matrixClient = fakeMatrixClient,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
}
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
}
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) {
Optional.empty()
} else {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
}
Result.success(resolvedRoomAlias)
}
)
private fun createConfigureRoomPresenter(
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore(), roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(),
pickerProvider: PickerProvider = FakePickerProvider(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,
mediaPreProcessor = mediaPreProcessor,
analyticsService = analyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
roomAliasHelper = roomAliasHelper,
featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}

View file

@ -1,140 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.FakeCreateRoomNavigator
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class JoinBaseRoomByAddressPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createJoinRoomByAddressPresenter()
presenter.test {
with(awaitItem()) {
assertThat(address).isEmpty()
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
}
}
}
@Test
fun `present - invalid address`() = runTest {
val presenter = createJoinRoomByAddressPresenter(
roomAliasHelper = FakeRoomAliasHelper(
isRoomAliasValidLambda = { false }
)
)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.UpdateAddress("invalid_address"))
}
with(awaitItem()) {
assertThat(address).isEqualTo("invalid_address")
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
eventSink(JoinRoomByAddressEvents.Continue)
}
// The address should be marked as invalid only after the user tries to continue
with(awaitItem()) {
assertThat(address).isEqualTo("invalid_address")
assertThat(addressState).isEqualTo(RoomAddressState.Invalid)
}
}
}
@Test
fun `present - room found`() = runTest {
val openRoomLambda = lambdaRecorder<RoomIdOrAlias, List<String>, Unit> { _, _ -> }
val dismissJoinRoomByAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeCreateRoomNavigator(
openRoomLambda = openRoomLambda,
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
)
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_found:matrix.org"))
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_found:matrix.org")
assertThat(addressState).isInstanceOf(RoomAddressState.RoomFound::class.java)
eventSink(JoinRoomByAddressEvents.Continue)
}
assert(openRoomLambda).isCalledOnce()
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
}
}
@Test
fun `present - room not found`() = runTest {
val presenter = createJoinRoomByAddressPresenter(
matrixClient = FakeMatrixClient(
resolveRoomAliasResult = { Result.failure(RuntimeException()) }
)
)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_not_found:matrix.org"))
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_not_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
eventSink(JoinRoomByAddressEvents.Continue)
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_not_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.Resolving)
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_not_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.RoomNotFound)
}
}
}
@Test
fun `present - dismiss`() = runTest {
val dismissJoinRoomByAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeCreateRoomNavigator(
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
)
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.Dismiss)
}
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
}
}
private fun createJoinRoomByAddressPresenter(
navigator: CreateRoomNavigator = FakeCreateRoomNavigator(),
matrixClient: MatrixClient = FakeMatrixClient(),
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
): JoinRoomByAddressPresenter {
return JoinRoomByAddressPresenter(
navigator = navigator,
client = matrixClient,
roomAliasHelper = roomAliasHelper,
)
}
}

View file

@ -1,63 +0,0 @@
/*
* 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.createroom.impl.joinbyaddress
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class JoinBaseRoomByAddressViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `entering text emits the expected event`() {
val eventsRecorder = EventsRecorder<JoinRoomByAddressEvents>()
rule.setJoinRoomByAddressView(
aJoinRoomByAddressState(
eventSink = eventsRecorder,
)
)
val text = rule.activity.getString(R.string.screen_start_chat_join_room_by_address_action)
rule.onNodeWithText(text).performTextInput("#address:matrix.org")
eventsRecorder.assertSingle(JoinRoomByAddressEvents.UpdateAddress("#address:matrix.org"))
}
@Test
fun `clicking on continue emits the expected event`() {
val eventsRecorder = EventsRecorder<JoinRoomByAddressEvents>()
rule.setJoinRoomByAddressView(
aJoinRoomByAddressState(
eventSink = eventsRecorder,
)
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(JoinRoomByAddressEvents.Continue)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
state: JoinRoomByAddressState,
) {
setSafeContent {
JoinRoomByAddressView(state = state)
}
}

View file

@ -1,199 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.MutableState
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.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class CreateBaseRoomRootPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - start DM action failure scenario`() = runTest {
val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMFailureResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
assertThat(initialState.userListState.selectedUsers).isEmpty()
assertThat(initialState.userListState.isSearchActive).isFalse()
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
val matrixUser = MatrixUser(UserId("@name:domain"))
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
state.eventSink(CreateRoomRootEvents.CancelStartDM)
}
awaitItem().also { state ->
assertThat(state.startDmAction.isUninitialized()).isTrue()
}
}
}
@Test
fun `present - start DM action success scenario`() = runTest {
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMSuccessResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
assertThat(initialState.userListState.selectedUsers).isEmpty()
assertThat(initialState.userListState.isSearchActive).isFalse()
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
val matrixUser = MatrixUser(UserId("@name:domain"))
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
}
}
}
@Test
fun `present - start DM action confirmation scenario - cancel`() = runTest {
val matrixUser = MatrixUser(UserId("@name:domain"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
val confirmingState = awaitItem()
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
// Cancelling should not create the DM
confirmingState.eventSink(CreateRoomRootEvents.CancelStartDM)
val finalState = awaitItem()
assertThat(finalState.startDmAction.isUninitialized()).isTrue()
executeResult.assertions().isCalledExactly(1)
}
}
@Test
fun `present - start DM action confirmation scenario - confirm`() = runTest {
val matrixUser = MatrixUser(UserId("@name:domain"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
val confirmingState = awaitItem()
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
// Start DM again should invoke the action with createIfDmDoesNotExist = true
confirmingState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
executeResult.assertions().isCalledExactly(2).withSequence(
listOf(value(matrixUser), value(false), any()),
listOf(value(matrixUser), value(true), any()),
)
}
}
@Test
fun `present - room directory search`() = runTest {
val presenter = createCreateRoomRootPresenter(isRoomDirectorySearchEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().let { state ->
assertThat(state.isRoomDirectorySearchEnabled).isTrue()
}
}
}
private fun createCreateRoomRootPresenter(
startDMAction: StartDMAction = FakeStartDMAction(),
isRoomDirectorySearchEnabled: Boolean = false,
): CreateRoomRootPresenter {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.RoomDirectorySearch.key to isRoomDirectorySearchEnabled,
),
)
return CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
userRepository = FakeUserRepository(),
userListDataStore = UserListDataStore(),
startDMAction = startDMAction,
featureFlagService = featureFlagService,
buildMeta = aBuildMeta(),
)
}
}

View file

@ -1,156 +0,0 @@
/*
* 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.
*/
package io.element.android.features.createroom.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.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class CreateBaseRoomRootViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onCloseClick = it
)
rule.pressBack()
}
}
@Test
fun `clicking on New room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onNewRoomClick = it
)
rule.clickOn(R.string.screen_create_room_action_create_room)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Invite people invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
applicationName = "test",
eventSink = eventsRecorder,
),
onInviteFriendsClick = it
)
val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test")
rule.onNodeWithText(text).performClick()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on a user suggestion invokes the expected callback`() {
val recentDirectRoomList = aRecentDirectRoomList()
val firstRoom = recentDirectRoomList[0]
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnceWithParam(firstRoom.roomId) {
rule.setCreateRoomRootView(
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = recentDirectRoomList
),
eventSink = eventsRecorder,
),
onOpenDM = it
)
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Join room by address invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onJoinRoomByAddressClick = it
)
rule.clickOn(R.string.screen_start_chat_join_room_by_address_action)
}
}
@Test
fun `clicking on room directory invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
isRoomDirectorySearchEnabled = true
),
onRoomDirectorySearchClick = it
)
rule.clickOn(R.string.screen_room_directory_search_title)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
state: CreateRoomRootState,
onCloseClick: () -> Unit = EnsureNeverCalled(),
onNewRoomClick: () -> Unit = EnsureNeverCalled(),
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
CreateRoomRootView(
state = state,
onCloseClick = onCloseClick,
onNewRoomClick = onNewRoomClick,
onOpenDM = onOpenDM,
onInviteFriendsClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinRoomByAddressClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
)
}
}

View file

@ -1,232 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
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.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.api.UserSearchResultState
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultUserListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val userRepository = FakeUserRepository()
@Test
fun `present - initial state for single selection`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
}
}
@Test
fun `present - initial state for multiple selection`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userRepository,
UserListDataStore(),
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
}
}
@Test
fun `present - update search query`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.OnSearchActiveChanged(true))
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery)
skipItems(1)
val notMatrixIdQuery = "name"
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery)
skipItems(1)
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - presents search results`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the user repository emits a result, it's copied to the state
val result = UserSearchResultState(
results = listOf(UserSearchResult(aMatrixUser())),
isSearching = false,
)
userRepository.emitState(result)
awaitItem().also { state ->
assertThat(state.searchResults).isEqualTo(
SearchBarResultState.Results(
persistentListOf(UserSearchResult(aMatrixUser()))
)
)
assertThat(state.showSearchLoader).isFalse()
}
// When the user repository emits another result, it replaces the previous value
val newResult = UserSearchResultState(
results = aMatrixUserList().map { UserSearchResult(it) },
isSearching = false,
)
userRepository.emitState(newResult)
awaitItem().also { state ->
assertThat(state.searchResults).isEqualTo(
SearchBarResultState.Results(
aMatrixUserList().map { UserSearchResult(it) }
)
)
assertThat(state.showSearchLoader).isFalse()
}
}
}
@Test
fun `present - presents search results when not found`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the results list is empty, the state is set to NoResults
userRepository.emitState(UserSearchResultState(results = emptyList(), isSearching = false))
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
}
}
@Test
fun `present - select a user`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val userA = aMatrixUser("@userA:domain", "A")
val userB = aMatrixUser("@userB:domain", "B")
val userABis = aMatrixUser("@userA:domain", "A")
val userC = aMatrixUser("@userC:domain", "C")
initialState.eventSink(UserListEvents.AddToSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userA)
initialState.eventSink(UserListEvents.AddToSelection(userB))
assertThat(awaitItem().selectedUsers).containsExactly(userA, userB)
initialState.eventSink(UserListEvents.AddToSelection(userABis))
initialState.eventSink(UserListEvents.AddToSelection(userC))
// duplicated users should be ignored
assertThat(awaitItem().selectedUsers).containsExactly(userA, userB, userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
assertThat(awaitItem().selectedUsers).containsExactly(userA, userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
assertThat(awaitItem().selectedUsers).isEmpty()
}
}
}

View file

@ -1,23 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import androidx.compose.runtime.Composable
class FakeUserListPresenter : UserListPresenter {
private var state = aUserListState()
fun givenState(state: UserListState) {
this.state = state
}
@Composable
override fun present(): UserListState {
return state
}
}

View file

@ -1,20 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.usersearch.api.UserRepository
class FakeUserListPresenterFactory(
private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
) : UserListPresenter.Factory {
override fun create(
args: UserListPresenterArgs,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): UserListPresenter = fakeUserListPresenter
}