Merge pull request #4302 from element-hq/feature/fga/join_room_by_alias

Feature : join room by address
This commit is contained in:
ganfra
2025-02-26 09:20:39 +01:00
committed by GitHub
58 changed files with 880 additions and 122 deletions

View File

@@ -357,8 +357,8 @@ class LoggedInFlowNode @AssistedInject constructor(
}
NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback {
override fun onSuccess(roomId: RoomId) {
backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias()))
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
}
}

View File

@@ -11,7 +11,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@@ -21,6 +21,6 @@ interface CreateRoomEntryPoint : FeatureEntryPoint {
}
interface Callback : Plugin {
fun onSuccess(roomId: RoomId)
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.features.createroom.impl.CreateRoomFlowNode.NavTarget
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
interface CreateRoomNavigator : Plugin {
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
fun onCreateNewRoom()
fun onShowJoinRoomByAddress()
fun onDismissJoinRoomByAddress()
}
class DefaultCreateRoomNavigator(
private val backstack: BackStack<NavTarget>,
private val overlay: Overlay<NavTarget>,
private val openRoom: (RoomIdOrAlias, List<String>) -> Unit,
) : CreateRoomNavigator {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) = openRoom(roomIdOrAlias, serverNames)
override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom)
}
override fun onShowJoinRoomByAddress() {
overlay.show(NavTarget.JoinByAddress)
}
override fun onDismissJoinRoomByAddress() {
overlay.hide()
}
}

View File

@@ -19,6 +19,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.features.createroom.impl.di.CreateRoomComponent
@@ -46,6 +47,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
private val component by lazy {
parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build()
}
private val navigator = plugins<CreateRoomNavigator>().first()
override val daggerComponent: Any
get() = component
@@ -69,8 +71,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
}
NavTarget.ConfigureRoom -> {
val callbacks = plugins<ConfigureRoomNode.Callback>()
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = callbacks)
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = listOf(navigator))
}
}
}

View File

@@ -8,25 +8,28 @@
package io.element.android.features.createroom.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.DefaultCreateRoomNavigator
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.features.createroom.impl.joinbyaddress.JoinRoomByAddressNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.OverlayView
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -47,35 +50,38 @@ class CreateRoomFlowNode @AssistedInject constructor(
@Parcelize
data object NewRoom : NavTarget
@Parcelize
data object JoinByAddress : NavTarget
}
private val navigator = DefaultCreateRoomNavigator(
backstack = backstack,
overlay = overlay,
openRoom = { roomIdOrAlias, viaServers ->
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomIdOrAlias, viaServers) }
}
)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : CreateRoomRootNode.Callback {
override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom)
}
override fun onStartChatSuccess(roomId: RoomId) {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
}
}
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(callback))
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.NewRoom -> {
val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
}
}
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(callback))
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(navigator))
}
NavTarget.JoinByAddress -> {
createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
Box(modifier = modifier) {
BackstackView()
OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() })
}
}
}

View File

@@ -18,8 +18,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(CreateRoomScope::class)
@@ -29,6 +30,8 @@ class ConfigureRoomNode @AssistedInject constructor(
private val presenter: ConfigureRoomPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<CreateRoomNavigator>().first()
init {
lifecycle.subscribe(
onResume = {
@@ -37,14 +40,6 @@ class ConfigureRoomNode @AssistedInject constructor(
)
}
interface Callback : Plugin {
fun onCreateRoomSuccess(roomId: RoomId)
}
private fun onCreateRoomSuccess(roomId: RoomId) {
plugins<Callback>().forEach { it.onCreateRoomSuccess(roomId) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -52,7 +47,9 @@ class ConfigureRoomNode @AssistedInject constructor(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onCreateRoomSuccess = this::onCreateRoomSuccess,
onCreateRoomSuccess = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
)
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.joinbyaddress
sealed interface JoinRoomByAddressEvents {
data object Dismiss : JoinRoomByAddressEvents
data object Continue : JoinRoomByAddressEvents
data class UpdateAddress(val address: String) : JoinRoomByAddressEvents
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.joinbyaddress
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class JoinRoomByAddressNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: JoinRoomByAddressPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<CreateRoomNavigator>().first()
private val presenter = presenterFactory.create(navigator)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
JoinRoomByAddressView(
state = state,
modifier = modifier
)
}
}

View File

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

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.joinbyaddress
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
data class JoinRoomByAddressState(
val address: String,
val addressState: RoomAddressState,
val eventSink: (JoinRoomByAddressEvents) -> Unit
)
@Immutable
sealed interface RoomAddressState {
data object Unknown : RoomAddressState
data object Invalid : RoomAddressState
data object Resolving : RoomAddressState
data object RoomNotFound : RoomAddressState
data class RoomFound(val resolved: ResolvedRoomAlias) : RoomAddressState
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.joinbyaddress
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
open class JoinRoomByAddressStateProvider : PreviewParameterProvider<JoinRoomByAddressState> {
override val values: Sequence<JoinRoomByAddressState>
get() = sequenceOf(
aJoinRoomByAddressState(),
aJoinRoomByAddressState(address = "#room-"),
aJoinRoomByAddressState(address = "#room-", addressState = RoomAddressState.Invalid),
aJoinRoomByAddressState(address = "#room-name:matrix.org", addressState = RoomAddressState.Resolving),
aJoinRoomByAddressState(address = "#room-name-none:matrix.org", addressState = RoomAddressState.RoomNotFound),
aJoinRoomByAddressState(
address = "#room-name:matrix.org",
addressState = RoomAddressState.RoomFound(ResolvedRoomAlias(RoomId("!aRoom:id"), emptyList())),
),
)
}
fun aJoinRoomByAddressState(
address: String = "",
addressState: RoomAddressState = RoomAddressState.Unknown,
eventSink: (JoinRoomByAddressEvents) -> Unit = {},
) = JoinRoomByAddressState(
address = address,
addressState = addressState,
eventSink = eventSink
)

View File

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

View File

@@ -20,9 +20,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
@@ -33,18 +34,7 @@ class CreateRoomRootNode @AssistedInject constructor(
private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCreateNewRoom()
fun onStartChatSuccess(roomId: RoomId)
}
private fun onCreateNewRoom() {
plugins<Callback>().forEach { it.onCreateNewRoom() }
}
private fun onStartChatSuccess(roomId: RoomId) {
plugins<Callback>().forEach { it.onStartChatSuccess(roomId) }
}
private val navigator = plugins<CreateRoomNavigator>().first()
init {
lifecycle.subscribe(
@@ -60,8 +50,11 @@ class CreateRoomRootNode @AssistedInject constructor(
state = state,
modifier = modifier,
onCloseClick = this::navigateUp,
onNewRoomClick = ::onCreateNewRoom,
onOpenDM = ::onStartChatSuccess,
onNewRoomClick = navigator::onCreateNewRoom,
onOpenDM = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
onInviteFriendsClick = { invitePeople(activity) }
)
}

View File

@@ -55,6 +55,7 @@ fun CreateRoomRootView(
onNewRoomClick: () -> Unit,
onOpenDM: (RoomId) -> Unit,
onInviteFriendsClick: () -> Unit,
onJoinByAddressClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -89,6 +90,7 @@ fun CreateRoomRootView(
state = state,
onNewRoomClick = onNewRoomClick,
onInvitePeopleClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinByAddressClick,
onDmClick = onOpenDM,
)
}
@@ -153,6 +155,7 @@ private fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
onNewRoomClick: () -> Unit,
onInvitePeopleClick: () -> Unit,
onJoinByAddressClick: () -> Unit,
onDmClick: (RoomId) -> Unit,
) {
LazyColumn {
@@ -170,6 +173,13 @@ private fun CreateRoomActionButtonsList(
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(
@@ -230,6 +240,7 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv
onCloseClick = {},
onNewRoomClick = {},
onOpenDM = {},
onJoinByAddressClick = {},
onInviteFriendsClick = {},
)
}

View File

@@ -20,4 +20,10 @@ You can change this anytime in room settings."</string>
<string name="screen_create_room_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</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>

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
class FakeCreateRoomNavigator(
private val openRoomLambda: (roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) -> Unit = { _, _ -> },
private val createNewRoomLambda: () -> Unit = {},
private val showJoinRoomByAddressLambda: () -> Unit = {},
private val dismissJoinRoomByAddressLambda: () -> Unit = {},
) : CreateRoomNavigator {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
openRoomLambda(roomIdOrAlias, serverNames)
}
override fun onCreateNewRoom() {
createNewRoomLambda()
}
override fun onShowJoinRoomByAddress() {
showJoinRoomByAddressLambda()
}
override fun onDismissJoinRoomByAddress() {
dismissJoinRoomByAddressLambda()
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.joinbyaddress
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.CreateRoomNavigator
import io.element.android.features.createroom.impl.FakeCreateRoomNavigator
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class JoinRoomByAddressPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createJoinRoomByAddressPresenter()
presenter.test {
with(awaitItem()) {
assertThat(address).isEmpty()
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
}
}
}
@Test
fun `present - invalid address`() = runTest {
val presenter = createJoinRoomByAddressPresenter(
roomAliasHelper = FakeRoomAliasHelper(
isRoomAliasValidLambda = { false }
)
)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.UpdateAddress("invalid_address"))
}
with(awaitItem()) {
assertThat(address).isEqualTo("invalid_address")
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
eventSink(JoinRoomByAddressEvents.Continue)
}
// The address should be marked as invalid only after the user tries to continue
with(awaitItem()) {
assertThat(address).isEqualTo("invalid_address")
assertThat(addressState).isEqualTo(RoomAddressState.Invalid)
}
}
}
@Test
fun `present - room found`() = runTest {
val openRoomLambda = lambdaRecorder<RoomIdOrAlias, List<String>, Unit> { _, _ -> }
val dismissJoinRoomByAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeCreateRoomNavigator(
openRoomLambda = openRoomLambda,
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
)
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_found:matrix.org"))
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_found:matrix.org")
assertThat(addressState).isInstanceOf(RoomAddressState.RoomFound::class.java)
eventSink(JoinRoomByAddressEvents.Continue)
}
assert(openRoomLambda).isCalledOnce()
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
}
}
@Test
fun `present - room not found`() = runTest {
val presenter = createJoinRoomByAddressPresenter(
matrixClient = FakeMatrixClient(
resolveRoomAliasResult = { Result.failure(RuntimeException()) }
)
)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_not_found:matrix.org"))
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_not_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
eventSink(JoinRoomByAddressEvents.Continue)
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_not_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.Resolving)
}
with(awaitItem()) {
assertThat(address).isEqualTo("#room_not_found:matrix.org")
assertThat(addressState).isEqualTo(RoomAddressState.RoomNotFound)
}
}
}
@Test
fun `present - dismiss`() = runTest {
val dismissJoinRoomByAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeCreateRoomNavigator(
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
)
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(JoinRoomByAddressEvents.Dismiss)
}
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
}
}
private fun createJoinRoomByAddressPresenter(
navigator: CreateRoomNavigator = FakeCreateRoomNavigator(),
matrixClient: MatrixClient = FakeMatrixClient(),
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
): JoinRoomByAddressPresenter {
return JoinRoomByAddressPresenter(
navigator = navigator,
client = matrixClient,
roomAliasHelper = roomAliasHelper,
)
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.joinbyaddress
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class JoinRoomByAddressViewTest {
@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,
) {
setContent {
JoinRoomByAddressView(state = state)
}
}

View File

@@ -101,6 +101,21 @@ class CreateRoomRootViewTest {
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Join room by address invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onJoinRoomByAddressClick = it
)
rule.clickOn(R.string.screen_start_chat_join_room_by_address_action)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
@@ -109,6 +124,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
onNewRoomClick: () -> Unit = EnsureNeverCalled(),
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
CreateRoomRootView(
@@ -117,6 +133,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
onNewRoomClick = onNewRoomClick,
onOpenDM = onOpenDM,
onInviteFriendsClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinRoomByAddressClick
)
}
}

View File

@@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
import io.element.android.libraries.designsystem.theme.components.Button
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
@Composable
@@ -90,7 +91,7 @@ fun BugReportView(
keyboardController?.hide()
}),
minLines = 3,
isError = state.isDescriptionInError,
validity = if (state.isDescriptionInError) TextFieldValidity.Invalid else TextFieldValidity.None,
)
}
Spacer(modifier = Modifier.height(16.dp))

View File

@@ -152,8 +152,7 @@ private fun RoomListScaffold(
onClick = onCreateRoomClick
) {
Icon(
// Note cannot use Icons.Outlined.EditSquare, it does not exist :/
imageVector = CompoundIcons.Compose(),
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message),
tint = ElementTheme.colors.iconOnSolidPrimary,
)

View File

@@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -99,7 +100,7 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool
Icon(imageVector = image, description)
}
},
isError = hasError,
validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = if (hasError) {
stringResource(R.string.screen_reset_encryption_password_error)
} else {

View File

@@ -58,7 +58,7 @@ fun TextField(
placeholder: String? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
validity: TextFieldValidity = TextFieldValidity.None,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = false,
@@ -93,7 +93,7 @@ fun TextField(
readOnly = readOnly,
enabled = enabled,
isFocused = isFocused,
isError = isError,
validity = validity,
leadingIcon = leadingIcon,
placeholder = placeholder,
isTextEmpty = value.isEmpty(),
@@ -114,7 +114,7 @@ fun TextField(
placeholder: String? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
validity: TextFieldValidity? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = false,
@@ -149,7 +149,7 @@ fun TextField(
readOnly = readOnly,
enabled = enabled,
isFocused = isFocused,
isError = isError,
validity = validity,
leadingIcon = leadingIcon,
placeholder = placeholder,
isTextEmpty = value.text.isEmpty(),
@@ -166,7 +166,7 @@ private fun DecorationBox(
enabled: Boolean,
readOnly: Boolean,
isFocused: Boolean,
isError: Boolean,
validity: TextFieldValidity?,
placeholder: String?,
isTextEmpty: Boolean,
supportingText: String?,
@@ -187,7 +187,7 @@ private fun DecorationBox(
enabled = enabled,
readOnly = readOnly,
isFocused = isFocused,
isError = isError
isError = validity == TextFieldValidity.Invalid
) {
Row(modifier = Modifier.padding(16.dp)) {
if (leadingIcon != null) {
@@ -216,7 +216,7 @@ private fun DecorationBox(
}
if (supportingText != null) {
Spacer(modifier = Modifier.height(4.dp))
SupportingTextLayout(isError, supportingText)
SupportingTextLayout(validity, supportingText)
}
}
}
@@ -254,24 +254,45 @@ private fun TextFieldContainer(
}
@Composable
private fun SupportingTextLayout(isError: Boolean, supportingText: String) {
private fun SupportingTextLayout(validity: TextFieldValidity?, supportingText: String) {
Row(horizontalArrangement = spacedBy(4.dp)) {
if (isError) {
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = ElementTheme.colors.iconCriticalPrimary
)
when (validity) {
TextFieldValidity.Invalid -> {
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = ElementTheme.colors.iconCriticalPrimary
)
}
TextFieldValidity.Valid -> {
Icon(
imageVector = CompoundIcons.CheckCircleSolid(),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = ElementTheme.colors.iconSuccessPrimary
)
}
else -> Unit
}
Text(
text = supportingText,
color = if (isError) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary,
color = when (validity) {
TextFieldValidity.Invalid -> ElementTheme.colors.textCriticalPrimary
TextFieldValidity.Valid -> ElementTheme.colors.textSuccessPrimary
else -> ElementTheme.colors.textSecondary
},
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
enum class TextFieldValidity {
None,
Invalid,
Valid
}
@Composable
private fun textFieldStyle(enabled: Boolean): TextStyle {
return ElementTheme.typography.fontBodyLgRegular.copy(
@@ -283,11 +304,11 @@ private fun textFieldStyle(enabled: Boolean): TextStyle {
)
}
@Preview(group = PreviewGroup.TextFields)
@Preview(group = PreviewGroup.TextFields, heightDp = 1000)
@Composable
internal fun TextFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview(group = PreviewGroup.TextFields)
@Preview(group = PreviewGroup.TextFields, heightDp = 1000)
@Composable
internal fun TextFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() }
@@ -295,15 +316,15 @@ internal fun TextFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() }
@ExcludeFromCoverage
private fun ContentToPreview() {
Column(modifier = Modifier.padding(4.dp)) {
allBooleans.forEach { isError ->
TextFieldValidity.entries.forEach { validity ->
allBooleans.forEach { enabled ->
allBooleans.forEach { readonly ->
TextField(
onValueChange = {},
label = "Label",
value = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}",
value = "Hello val=$validity, en=${enabled.asInt()}, ro=${readonly.asInt()}",
supportingText = "Supporting text",
isError = isError,
validity = validity,
enabled = enabled,
readOnly = readonly,
)

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -56,7 +57,10 @@ fun RoomAddressField(
}
else -> supportingText
},
isError = addressValidity.isError(),
validity = when (addressValidity) {
RoomAddressValidity.InvalidSymbols, RoomAddressValidity.NotAvailable -> TextFieldValidity.Invalid
else -> TextFieldValidity.None
},
onValueChange = onAddressChange,
singleLine = true,
)

View File

@@ -19,8 +19,4 @@ sealed interface RoomAddressValidity {
data object InvalidSymbols : RoomAddressValidity
data object NotAvailable : RoomAddressValidity
data object Valid : RoomAddressValidity
fun isError(): Boolean {
return this is InvalidSymbols || this is NotAvailable
}
}

View File

@@ -64,7 +64,8 @@
"includeRegex" : [
"screen_create_room_.*",
"screen\\.create_room\\..*",
"screen_start_chat_.*"
"screen_start_chat_.*",
"screen\\.start_chat\\..*"
]
},
{