First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.startchat.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.createroom.api)
api(projects.features.startchat.api)
testCommonDependencies(libs, true)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat
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.startchat.impl.StartChatFlowNode.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 StartChatNavigator : Plugin {
fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
fun onCreateNewRoom()
fun onShowJoinRoomByAddress()
fun onDismissJoinRoomByAddress()
fun onOpenRoomDirectory()
}
class DefaultStartChatNavigator(
private val backstack: BackStack<NavTarget>,
private val overlay: Overlay<NavTarget>,
private val openRoom: (RoomIdOrAlias, List<String>) -> Unit,
private val openRoomDirectory: () -> Unit,
) : StartChatNavigator {
override fun onRoomCreated(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()
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultStartChatEntryPoint : StartChatEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: StartChatEntryPoint.Callback,
): Node {
return parentNode.createNode<StartChatFlowNode>(buildContext, listOf(callback))
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl
import androidx.compose.runtime.MutableState
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.startchat.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
@ContributesBinding(SessionScope::class)
class DefaultStartDMAction(
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)
}
}
}
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.startchat.DefaultStartChatNavigator
import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode
import io.element.android.features.startchat.impl.root.StartChatNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.OverlayView
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class StartChatFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val createRoomEntryPoint: CreateRoomEntryPoint,
) : BaseFlowNode<StartChatFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
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 callback: StartChatEntryPoint.Callback = callback()
private val navigator = DefaultStartChatNavigator(
backstack = backstack,
overlay = overlay,
openRoom = callback::onRoomCreated,
openRoomDirectory = callback::navigateToRoomDirectory,
)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode<StartChatNode>(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.NewRoom -> {
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) {
navigator.onRoomCreated(roomId.toRoomIdOrAlias(), emptyList())
}
}
createRoomEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
NavTarget.JoinByAddress -> {
createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))
}
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
BackstackView()
OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() })
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 = {}
)
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 = {},
)
}
}
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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()
}
}
}
}
},
)
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.startchat.impl.userlist.UserListEvents
import io.element.android.features.startchat.impl.userlist.UserListState
import io.element.android.features.startchat.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 = {},
)
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.joinbyaddress
sealed interface JoinRoomByAddressEvents {
data object Dismiss : JoinRoomByAddressEvents
data object Continue : JoinRoomByAddressEvents
data class UpdateAddress(val address: String) : JoinRoomByAddressEvents
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.startchat.StartChatNavigator
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class JoinRoomByAddressNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: JoinRoomByAddressPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<StartChatNavigator>().first()
private val presenter = presenterFactory.create(navigator)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
JoinRoomByAddressView(
state = state,
modifier = modifier
)
}
}
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.startchat.StartChatNavigator
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
@AssistedInject
class JoinRoomByAddressPresenter(
@Assisted private val navigator: StartChatNavigator,
private val client: MatrixClient,
private val roomAliasHelper: RoomAliasHelper,
) : Presenter<JoinRoomByAddressState> {
@AssistedFactory
interface Factory {
fun create(navigator: StartChatNavigator): 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 handleEvent(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 = ::handleEvent,
)
}
private fun onRoomFound(state: RoomAddressState.RoomFound) {
navigator.onDismissJoinRoomByAddress()
navigator.onRoomCreated(
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
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
)
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.startchat.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)
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.root
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface StartChatEvents {
data class StartDM(val matrixUser: MatrixUser) : StartChatEvents
data object CancelStartDM : StartChatEvents
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.startchat.StartChatNavigator
import io.element.android.libraries.deeplink.api.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)
@AssistedInject
class StartChatNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: StartChatPresenter,
private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<StartChatNavigator>().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)
StartChatView(
state = state,
modifier = modifier,
onCloseClick = this::navigateUp,
onNewRoomClick = navigator::onCreateNewRoom,
onOpenDM = {
navigator.onRoomCreated(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
onInviteFriendsClick = { invitePeople(activity) },
onRoomDirectorySearchClick = navigator::onOpenRoomDirectory
)
}
private fun invitePeople(activity: Activity) {
inviteFriendsUseCase.execute(activity)
}
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 dev.zacsweers.metro.Inject
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.features.startchat.impl.userlist.SelectionMode
import io.element.android.features.startchat.impl.userlist.UserListDataStore
import io.element.android.features.startchat.impl.userlist.UserListPresenter
import io.element.android.features.startchat.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
@Inject
class StartChatPresenter(
presenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
private val startDMAction: StartDMAction,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
) : Presenter<StartChatState> {
private val presenter = presenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
userListDataStore,
)
@Composable
override fun present(): StartChatState {
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 handleEvent(event: StartChatEvents) {
when (event) {
is StartChatEvents.StartDM -> localCoroutineScope.launch {
startDMAction.execute(
matrixUser = event.matrixUser,
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
actionState = startDmActionState,
)
}
StartChatEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
}
}
return StartChatState(
applicationName = buildMeta.applicationName,
userListState = userListState,
startDmAction = startDmActionState.value,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.root
import io.element.android.features.startchat.impl.userlist.UserListState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
data class StartChatState(
val applicationName: String,
val userListState: UserListState,
val startDmAction: AsyncAction<RoomId>,
val isRoomDirectorySearchEnabled: Boolean,
val eventSink: (StartChatEvents) -> Unit,
)
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.startchat.impl.userlist.UserListState
import io.element.android.features.startchat.impl.userlist.aRecentDirectRoomList
import io.element.android.features.startchat.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 StartChatStateProvider : PreviewParameterProvider<StartChatState> {
override val values: Sequence<StartChatState>
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: (StartChatEvents) -> Unit = {},
) = StartChatState(
applicationName = applicationName,
userListState = userListState,
startDmAction = startDmAction,
isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = eventSink,
)
@@ -0,0 +1,254 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.startchat.impl.R
import io.element.android.features.startchat.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 StartChatView(
state: StartChatState,
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(StartChatEvents.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(StartChatEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(StartChatEvents.CancelStartDM)
},
onErrorDismiss = { state.eventSink(StartChatEvents.CancelStartDM) },
confirmationDialog = { data ->
if (data is ConfirmingStartDmWithMatrixUser) {
CreateDmConfirmationBottomSheet(
matrixUser = data.matrixUser,
onSendInvite = {
state.eventSink(StartChatEvents.StartDM(data.matrixUser))
},
onDismiss = {
state.eventSink(StartChatEvents.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: StartChatState,
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 StartChatViewPreview(@PreviewParameter(StartChatStateProvider::class) state: StartChatState) =
ElementPreview {
StartChatView(
state = state,
onCloseClick = {},
onNewRoomClick = {},
onOpenDM = {},
onJoinByAddressClick = {},
onInviteFriendsClick = {},
onRoomDirectorySearchClick = {},
)
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
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
@AssistedInject
class DefaultUserListPresenter(
@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)
}
fun handleEvent(event: UserListEvents) {
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)
}
}
return UserListState(
searchQuery = searchQuery,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
selectionMode = args.selectionMode,
recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.userlist
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@Inject
class UserListDataStore {
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()
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
}
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.userlist
data class UserListPresenterArgs(
val selectionMode: SelectionMode,
)
enum class SelectionMode {
Single,
Multiple,
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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)
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Новы пакой"</string>
<string name="screen_room_directory_search_title">"Каталог пакояў"</string>
<string name="screen_start_chat_error_starting_chat">"Пры спробе пачаць чат адбылася памылка"</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Нова стая"</string>
<string name="screen_start_chat_error_starting_chat">"Възникна грешка при опита за започване на чат"</string>
<string name="screen_start_chat_join_room_by_address_action">"Присъединяване към стая по адрес"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Не е валиден адрес"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Въведете…"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Стаята не е намерена"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"напр. #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nová místnost"</string>
<string name="screen_room_directory_search_title">"Adresář místností"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>
<string name="screen_start_chat_join_room_by_address_action">"Vstoupit do místnosti pomocí adresy"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Neplatná adresa"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Zadejte…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Odpovídající místnost nalezena"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Místnost nebyla nalezena"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"např. #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Ystafell newydd"</string>
<string name="screen_room_directory_search_title">"Cyfeiriadur ystafelloedd"</string>
<string name="screen_start_chat_error_starting_chat">"Digwyddodd gwall wrth geisio cychwyn sgwrs"</string>
<string name="screen_start_chat_join_room_by_address_action">"Ymuno â\'r ystafell yn ôl cyfeiriad"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ddim yn gyfeiriad dilys"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Ewch i mewn…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Cafwyd hyd i ystafell gyfatebol"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Heb ganfod yr ystafell"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"e.e. #enw-ystafell:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nyt rum"</string>
<string name="screen_room_directory_search_title">"Register over rum"</string>
<string name="screen_start_chat_error_starting_chat">"Der opstod en fejl under forsøget på at starte en samtale"</string>
<string name="screen_start_chat_join_room_by_address_action">"Tilslut dig rummet med adressen"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ikke en gyldig adresse"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Indtast…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Matchende rum fundet"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Rum ikke fundet"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"f.eks. #rummets-navn:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Chat"</string>
<string name="screen_room_directory_search_title">"Chat-Verzeichnis"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
<string name="screen_start_chat_join_room_by_address_action">"Chat per Adresse beitreten"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Keine gültige Adresse"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Eintreten…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Passender Chat gefunden"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Chat nicht gefunden"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"z. B. #room -name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Νέα αίθουσα"</string>
<string name="screen_room_directory_search_title">"Κατάλογος αιθουσών"</string>
<string name="screen_start_chat_error_starting_chat">"Παρουσιάστηκε σφάλμα κατά την προσπάθεια έναρξης μιας συνομιλίας"</string>
<string name="screen_start_chat_join_room_by_address_action">"Συμμετοχή σε αίθουσα μέσω διεύθυνσης"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Μη έγκυρη διεύθυνση"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Εισάγετε…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Βρέθηκε η αντίστοιχη αίθουσα"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Η αίθουσα δεν βρέθηκε"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"π.χ. #όνομα-αίθουσας:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nueva sala"</string>
<string name="screen_room_directory_search_title">"Directorio de salas"</string>
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Unirse a una sala por su dirección"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Dirección no válida"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Introducir…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Sala encontrada"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"No se encontró la sala"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"p. ej., #nombre-de-la-sala:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Uus jututuba"</string>
<string name="screen_room_directory_search_title">"Jututubade kataloog"</string>
<string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string>
<string name="screen_start_chat_join_room_by_address_action">"Liitu jututoaga aadressi alusel"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"See pole kehtiv aadress"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Sisene…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Leidsime vastava jututoa"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Jututuba ei leidu"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"nt. #jututoa-nimi:matrix.org"</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Gela berria"</string>
<string name="screen_room_directory_search_title">"Gelen direktorioa"</string>
<string name="screen_start_chat_error_starting_chat">"Errorea gertatu da txata hasten saiatzean"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ez da baliozko helbidea"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Sartu…"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Ez da gela aurkitu"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"اتاق جدید"</string>
<string name="screen_room_directory_search_title">"فهرست اتاق‌ها"</string>
<string name="screen_start_chat_error_starting_chat">"هنگام تلاش برای شروع چت خطایی روی داد"</string>
<string name="screen_start_chat_join_room_by_address_action">"پیوستن به اتاق با نشانی"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"نشانی معتبری نیست"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"ورود…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"اتاق مطابق پیدا شد"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"اتاق پیدا نشد"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"نمونه: #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Uusi huone"</string>
<string name="screen_room_directory_search_title">"Huoneluettelo"</string>
<string name="screen_start_chat_error_starting_chat">"Keskustelun aloituksessa tapahtui virhe"</string>
<string name="screen_start_chat_join_room_by_address_action">"Liity huoneeseen osoitteella"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Osoite ei ole kelvollinen"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Syötä…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Täsmäävä huone löytyi"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Huonetta ei löytynyt"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"esim. #huoneen-nimi:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nouveau salon"</string>
<string name="screen_room_directory_search_title">"Annuaire des salons"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string>
<string name="screen_start_chat_join_room_by_address_action">"Saisir une adresse de salon"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ce nest pas une adresse valide"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Saisir…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Ce salon existe"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Salon non trouvé"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"ex: #nom-du-salon:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Új szoba"</string>
<string name="screen_room_directory_search_title">"Szobakatalógus"</string>
<string name="screen_start_chat_error_starting_chat">"Hiba történt a csevegés indításakor"</string>
<string name="screen_start_chat_join_room_by_address_action">"Csatlakozás a szobához cím szerint"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Nem érvényes cím"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Írja be…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Megfelelő szoba található"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Szoba nem található"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"pl. #szoba-neve:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Ruangan baru"</string>
<string name="screen_room_directory_search_title">"Direktori ruangan"</string>
<string name="screen_start_chat_error_starting_chat">"Terjadi kesalahan saat mencoba memulai obrolan"</string>
<string name="screen_start_chat_join_room_by_address_action">"Bergabung dalam ruangan berdasarkan alamat"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Bukan alamat yang valid"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Masuk…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Ruangan yang cocok ditemukan"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Ruangan tidak ditemukan"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"mis. #nama-ruangan:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nuova stanza"</string>
<string name="screen_room_directory_search_title">"Elenco delle stanze"</string>
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Accedi alla stanza tramite indirizzo"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Indirizzo non valido"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Inserisci…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Stanza trovata"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Stanza non trovata"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"ad esempio #room -name:matrix.org"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"ახალი ოთახი"</string>
<string name="screen_room_directory_search_title">"ოთახის კატალოგი"</string>
<string name="screen_start_chat_error_starting_chat">"ჩატის დაწყების მცდელობისას შეცდომა მოხდა"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"새 방"</string>
<string name="screen_room_directory_search_title">"방 디렉토리"</string>
<string name="screen_start_chat_error_starting_chat">"채팅을 시작하는 동안 오류가 발생했습니다."</string>
<string name="screen_start_chat_join_room_by_address_action">"주소로 방에 참가하기"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"유효한 주소가 아닙니다"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"입력하다…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"일치하는 방이 발견되었습니다"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"방을 찾을 수 없습니다"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"예: #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Naujas kambarys"</string>
<string name="screen_start_chat_error_starting_chat">"Bandant pradėti pokalbį įvyko klaida"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nytt rom"</string>
<string name="screen_room_directory_search_title">"Romkatalog"</string>
<string name="screen_start_chat_error_starting_chat">"Det oppstod en feil når du prøvde å starte en chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Bli med i rommet med adresse"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ikke en gyldig adresse"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Gå inn…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Matchende rom funnet"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Rom ikke funnet"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"f.eks. #rom-navn:matrix.org"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nieuwe kamer"</string>
<string name="screen_room_directory_search_title">"Kamergids"</string>
<string name="screen_start_chat_error_starting_chat">"Er is een fout opgetreden bij het starten van een chat"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nowy pokój"</string>
<string name="screen_room_directory_search_title">"Katalog pokoi"</string>
<string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>
<string name="screen_start_chat_join_room_by_address_action">"Dołącz do pokoju za pomocą adresu"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Nieprawidłowy adres"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Wprowadź…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Znaleziono pasujący pokój"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Nie znaleziono pokoju"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"np. #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nova sala"</string>
<string name="screen_room_directory_search_title">"Diretório de salas"</string>
<string name="screen_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string>
<string name="screen_start_chat_join_room_by_address_action">"Entrar na sala pelo endereço"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Não é um endereço válido"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Digite…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Foi encontrada uma sala correspondente"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Sala não encontrada"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"Por exemplo, #nome-da-sala:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nova sala"</string>
<string name="screen_room_directory_search_title">"Diretório de salas"</string>
<string name="screen_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string>
<string name="screen_start_chat_join_room_by_address_action">"Entrar na sala pelo endereço"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Não é um endereço válido"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Entrar…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Sala correspondente encontrado"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Sala não encontrada"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"por exemplo, #sala:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_room_directory_search_title">"Director de camere"</string>
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
<string name="screen_start_chat_join_room_by_address_action">"Gasiți o cameră după adresă"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Adresa nu e este validă"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Introduceți…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"S-a găsit o cameră"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Nu a putut fi găsită nici o cameră"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"de exemplu #nume-camera:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Создать новую комнату"</string>
<string name="screen_room_directory_search_title">"Каталог комнат"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при попытке начать чат"</string>
<string name="screen_start_chat_join_room_by_address_action">"Присоединиться к комнате по адресу"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Недействительный адрес"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Ввести…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Соответствующая комната найдена"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Комната не найдена"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"прим. #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nová miestnosť"</string>
<string name="screen_room_directory_search_title">"Adresár miestností"</string>
<string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string>
<string name="screen_start_chat_join_room_by_address_action">"Pripojte sa do miestnosti podľa adresy"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Neplatná adresa"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Zadajte…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Nájdená zodpovedajúca miestnosť"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Miestnosť sa nenašla"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"napr. #nazov-miestnosti:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nytt rum"</string>
<string name="screen_room_directory_search_title">"Rumskatalog"</string>
<string name="screen_start_chat_error_starting_chat">"Ett fel uppstod när du försökte starta en chatt"</string>
<string name="screen_start_chat_join_room_by_address_action">"Gå med i rum med adress"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Inte en giltig adress"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Ange …"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Matchande rum hittades"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Rummet hittades inte"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"t.ex. #rumsnamn:matrix.org"</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Yeni oda"</string>
<string name="screen_room_directory_search_title">"Oda dizini"</string>
<string name="screen_start_chat_error_starting_chat">"Sohbet başlatmaya çalışırken bir hata oluştu"</string>
<string name="screen_start_chat_join_room_by_address_action">"Bir adres ile odaya katılın"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Geçerli bir adres değil"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Eşleşen oda bulundu"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Oda bulunamadı"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"örn. #room-isim:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Нова кімната"</string>
<string name="screen_room_directory_search_title">"Каталог кімнат"</string>
<string name="screen_start_chat_error_starting_chat">"Під час спроби почати бесіду сталася помилка"</string>
<string name="screen_start_chat_join_room_by_address_action">"Приєднатися до кімнати за адресою"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Недійсна адреса"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Введіть…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Знайдено відповідну кімнату"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Кімната не знайдена"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"наприклад, #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"نیا کمرہ"</string>
<string name="screen_room_directory_search_title">"کمرے کا راہنامچہ"</string>
<string name="screen_start_chat_error_starting_chat">"گفتگو شروع کرنے کی کوشش کرتے وقت ایک خرابی واقع ہوگئی"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Yangi xona"</string>
<string name="screen_room_directory_search_title">"Xona katalogi"</string>
<string name="screen_start_chat_error_starting_chat">"Suhbatni boshlashda xatolik yuz berdi"</string>
<string name="screen_start_chat_join_room_by_address_action">"Xonaga manzil orqali kirish"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Yaroqli manzil emas"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Kirish…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Mos xona topildi"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Xona topilmadi"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"masalan #xona-nomi:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"建立聊天室"</string>
<string name="screen_room_directory_search_title">"聊天室目錄"</string>
<string name="screen_start_chat_error_starting_chat">"嘗試開始聊天時發生錯誤"</string>
<string name="screen_start_chat_join_room_by_address_action">"按地址加入聊天室"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"不是有效的位址"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"輸入……"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"找到相符的聊天室"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"找不到聊天室"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"例如 #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"新聊天室"</string>
<string name="screen_room_directory_search_title">"聊天室目录"</string>
<string name="screen_start_chat_error_starting_chat">"在开始聊天时发生了错误"</string>
<string name="screen_start_chat_join_room_by_address_action">"输入地址加入房间"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"地址无效"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"输入…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"找到匹配的房间"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"未找到房间"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"例如 #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"New room"</string>
<string name="screen_room_directory_search_title">"Room directory"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Join room by address"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Not a valid address"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Enter…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Matching room found"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Room not found"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"e.g. #room-name:matrix.org"</string>
</resources>
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint
import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultStartChatEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultStartChatEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
StartChatFlowNode(
buildContext = buildContext,
plugins = plugins,
createRoomEntryPoint = FakeCreateRoomEntryPoint(),
)
}
val callback = object : StartChatEntryPoint.Callback {
override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) = lambdaError()
override fun navigateToRoomDirectory() = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result).isInstanceOf(StartChatFlowNode::class.java)
assertThat(result.plugins).contains(callback)
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.startchat.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,
)
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl
import io.element.android.features.startchat.StartChatNavigator
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
class FakeStartChatNavigator(
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 = {},
) : StartChatNavigator {
override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
openRoomLambda(roomIdOrAlias, serverNames)
}
override fun onCreateNewRoom() {
createNewRoomLambda()
}
override fun onShowJoinRoomByAddress() {
showJoinRoomByAddressLambda()
}
override fun onDismissJoinRoomByAddress() {
dismissJoinRoomByAddressLambda()
}
override fun onOpenRoomDirectory() {
openRoomDirectoryLambda()
}
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.joinbyaddress
import com.google.common.truth.Truth.assertThat
import io.element.android.features.startchat.StartChatNavigator
import io.element.android.features.startchat.impl.FakeStartChatNavigator
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 = FakeStartChatNavigator(
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 = FakeStartChatNavigator(
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
)
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.Dismiss)
}
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
}
}
private fun createJoinRoomByAddressPresenter(
navigator: StartChatNavigator = FakeStartChatNavigator(),
matrixClient: MatrixClient = FakeMatrixClient(),
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
): JoinRoomByAddressPresenter {
return JoinRoomByAddressPresenter(
navigator = navigator,
client = matrixClient,
roomAliasHelper = roomAliasHelper,
)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.startchat.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)
}
}
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.invitepeople.test.FakeStartDMAction
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.features.startchat.impl.userlist.FakeUserListPresenter
import io.element.android.features.startchat.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.startchat.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.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 StartChatPresenterTest {
@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 = createStartChatPresenter(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(StartChatEvents.StartDM(matrixUser))
awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
state.eventSink(StartChatEvents.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 = createStartChatPresenter(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(StartChatEvents.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 = createStartChatPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
initialState.eventSink(StartChatEvents.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(StartChatEvents.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 = createStartChatPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
initialState.eventSink(StartChatEvents.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(StartChatEvents.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 = createStartChatPresenter(isRoomDirectorySearchEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().let { state ->
assertThat(state.isRoomDirectorySearchEnabled).isTrue()
}
}
}
}
internal fun createStartChatPresenter(
startDMAction: StartDMAction = FakeStartDMAction(),
isRoomDirectorySearchEnabled: Boolean = false,
): StartChatPresenter {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.RoomDirectorySearch.key to isRoomDirectorySearchEnabled,
),
)
return StartChatPresenter(
presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
userRepository = FakeUserRepository(),
userListDataStore = UserListDataStore(),
startDMAction = startDMAction,
featureFlagService = featureFlagService,
buildMeta = aBuildMeta(),
)
}
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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.startchat.impl.R
import io.element.android.features.startchat.impl.userlist.aRecentDirectRoomList
import io.element.android.features.startchat.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 StartChatViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<StartChatEvents>(expectEvents = false)
ensureCalledOnce {
rule.setStartChatView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onCloseClick = it
)
rule.pressBack()
}
}
@Test
fun `clicking on New room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<StartChatEvents>(expectEvents = false)
ensureCalledOnce {
rule.setStartChatView(
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<StartChatEvents>(expectEvents = false)
ensureCalledOnce {
rule.setStartChatView(
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<StartChatEvents>(expectEvents = false)
ensureCalledOnceWithParam(firstRoom.roomId) {
rule.setStartChatView(
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<StartChatEvents>(expectEvents = false)
ensureCalledOnce {
rule.setStartChatView(
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<StartChatEvents>(expectEvents = false)
ensureCalledOnce {
rule.setStartChatView(
aCreateRoomRootState(
eventSink = eventsRecorder,
isRoomDirectorySearchEnabled = true
),
onRoomDirectorySearchClick = it
)
rule.clickOn(R.string.screen_room_directory_search_title)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setStartChatView(
state: StartChatState,
onCloseClick: () -> Unit = EnsureNeverCalled(),
onNewRoomClick: () -> Unit = EnsureNeverCalled(),
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
StartChatView(
state = state,
onCloseClick = onCloseClick,
onNewRoomClick = onNewRoomClick,
onOpenDM = onOpenDM,
onInviteFriendsClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinRoomByAddressClick,
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
)
}
}
@@ -0,0 +1,233 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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()
}
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.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
}