From f1c5fa53e813805016f0f4f2a77c80ebede0cbda Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 20 Feb 2025 20:59:42 +0100 Subject: [PATCH] feat(join by alias) : introduce the JoinRoomByAddress --- .../createroom/CreateRoomNavigator.kt | 10 ++ .../createroom/impl/CreateRoomFlowNode.kt | 14 ++- .../joinbyaddress/JoinRoomByAddressEvents.kt | 14 +++ .../joinbyaddress/JoinRoomByAddressNode.kt | 40 ++++++ .../JoinRoomByAddressPresenter.kt | 98 +++++++++++++++ .../joinbyaddress/JoinRoomByAddressState.kt | 23 ++++ .../JoinRoomByAddressStateProvider.kt | 31 +++++ .../joinbyaddress/JoinRoomByAddressView.kt | 114 ++++++++++++++++++ .../impl/root/CreateRoomRootNode.kt | 1 + .../impl/root/CreateRoomRootView.kt | 11 ++ 10 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt index b2d8ea6828..a7b15a4417 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/CreateRoomNavigator.kt @@ -19,10 +19,13 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias interface CreateRoomNavigator : Plugin { fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias) fun onCreateNewRoom() + fun onShowJoinRoomByAddress() + fun onDismissJoinRoomByAddress() } class DefaultCreateRoomNavigator( private val backstack: BackStack, + private val overlay: Overlay, private val openRoom: (RoomIdOrAlias) -> Unit, ) : CreateRoomNavigator { @@ -32,4 +35,11 @@ class DefaultCreateRoomNavigator( backstack.push(NavTarget.NewRoom) } + override fun onShowJoinRoomByAddress() { + overlay.show(NavTarget.JoinByAddress) + } + + override fun onDismissJoinRoomByAddress() { + overlay.hide() + } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt index 7b6185e0ca..7fb4830e0f 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -8,6 +8,7 @@ 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 @@ -22,9 +23,11 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.createroom.DefaultCreateRoomNavigator import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.features.createroom.impl.joinbyaddress.JoinRoomByAddressNode import io.element.android.features.createroom.impl.root.CreateRoomRootNode import io.element.android.libraries.architecture.BackstackView import io.element.android.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 @@ -50,10 +53,13 @@ class CreateRoomFlowNode @AssistedInject constructor( @Parcelize data object NewRoom : NavTarget + @Parcelize + data object JoinByAddress : NavTarget } private val navigator = DefaultCreateRoomNavigator( backstack = backstack, + overlay = overlay, openRoom = { roomIdOrAlias -> plugins().forEach { it.onOpenRoom(roomIdOrAlias) } } @@ -67,11 +73,17 @@ class CreateRoomFlowNode @AssistedInject constructor( NavTarget.NewRoom -> { createNode(buildContext = buildContext, plugins = listOf(navigator)) } + NavTarget.JoinByAddress -> { + createNode(buildContext = buildContext, plugins = listOf(navigator)) + } } } @Composable override fun View(modifier: Modifier) { - BackstackView() + Box(modifier = modifier) { + BackstackView() + OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() }) + } } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt new file mode 100644 index 0000000000..58a1223b8f --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressEvents.kt @@ -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 +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt new file mode 100644 index 0000000000..ae12caa00e --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressNode.kt @@ -0,0 +1,40 @@ +/* + * 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, + presenterFactory: JoinRoomByAddressPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + private val navigator = plugins().first() + private val presenter = presenterFactory.create(navigator) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + JoinRoomByAddressView( + state = state, + modifier = modifier + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt new file mode 100644 index 0000000000..d721deb467 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressPresenter.kt @@ -0,0 +1,98 @@ +/* + * 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.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.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +import kotlinx.coroutines.delay + +class JoinRoomByAddressPresenter @AssistedInject constructor( + @Assisted private val navigator: CreateRoomNavigator, + private val client: MatrixClient, + private val roomAliasHelper: RoomAliasHelper, +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(navigator: CreateRoomNavigator): JoinRoomByAddressPresenter + } + + @Composable + override fun present(): JoinRoomByAddressState { + var address by remember { mutableStateOf("") } + var addressState by remember { mutableStateOf(RoomAddressState.Unknown) } + + fun handleEvents(event: JoinRoomByAddressEvents) { + when (event) { + JoinRoomByAddressEvents.Continue -> { + navigator.onDismissJoinRoomByAddress() + navigator.onOpenRoom(RoomIdOrAlias.Alias(RoomAlias(address))) + } + JoinRoomByAddressEvents.Dismiss -> navigator.onDismissJoinRoomByAddress() + is JoinRoomByAddressEvents.UpdateAddress -> { + address = event.address.trim() + } + } + } + + RoomAddressStateEffect( + fullAddress = address, + onRoomAddressStateChange = { addressState = it } + ) + + return JoinRoomByAddressState( + address = address, + addressState = addressState, + eventSink = ::handleEvents + ) + } + + @Composable + private fun RoomAddressStateEffect( + fullAddress: String, + onRoomAddressStateChange: (RoomAddressState) -> Unit, + ) { + val onChange by rememberUpdatedState(onRoomAddressStateChange) + LaunchedEffect(fullAddress) { + if (fullAddress.isEmpty()) { + onChange(RoomAddressState.Unknown) + return@LaunchedEffect + } + // debounce the room address validation + delay(300) + val roomAlias = tryOrNull { RoomAlias(fullAddress) } + if (roomAlias == null || !roomAliasHelper.isRoomAliasValid(roomAlias)) { + onChange(RoomAddressState.Invalid) + } else { + onChange(RoomAddressState.Valid(matchingRoomFound = false)) + client.resolveRoomAlias(roomAlias) + .onSuccess { resolved -> + onChange(RoomAddressState.Valid(matchingRoomFound = resolved.isPresent)) + } + } + } + } +} + + + diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt new file mode 100644 index 0000000000..6d5be3dfef --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressState.kt @@ -0,0 +1,23 @@ +/* + * 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 + +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 class Valid(val matchingRoomFound: Boolean) : RoomAddressState +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt new file mode 100644 index 0000000000..de20b1bc1c --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt @@ -0,0 +1,31 @@ +/* + * 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 + +open class JoinRoomByAddressStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aJoinRoomByAddressState(), + aJoinRoomByAddressState("#room-"), + aJoinRoomByAddressState("#room-", addressState = RoomAddressState.Invalid), + aJoinRoomByAddressState("#room-name:matrix.org", addressState = RoomAddressState.Valid(true)), + aJoinRoomByAddressState("#room-name-here:matrix.org", addressState = RoomAddressState.Valid(false)), + // Add other states here + ) +} + +fun aJoinRoomByAddressState( + address: String = "", + addressState: RoomAddressState = RoomAddressState.Unknown, +) = JoinRoomByAddressState( + address = address, + addressState = addressState, + eventSink = {} +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt new file mode 100644 index 0000000000..ccc2fd6dbd --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinRoomByAddressView.kt @@ -0,0 +1,114 @@ +/* + * 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.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.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +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.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)) + } + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + text = stringResource(CommonStrings.action_continue), + modifier = Modifier.fillMaxWidth(), + enabled = state.addressState is RoomAddressState.Valid, + onClick = { + state.eventSink(JoinRoomByAddressEvents.Continue) + } + ) + } + } +} + +@Composable +private fun RoomAddressField( + address: String, + addressState: RoomAddressState, + requestFocus: Boolean, + onAddressChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + if (requestFocus) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + TextField( + modifier = modifier.focusRequester(focusRequester), + value = address, + label = "Join room by address", + placeholder = "Enter...", + supportingText = when (addressState) { + RoomAddressState.Invalid -> "Not a valid address" + RoomAddressState.Unknown -> "e.g. #room-name:matrix.org" + is RoomAddressState.Valid -> if (addressState.matchingRoomFound) { + "Matching room found" + } else { + "e.g. #room-name:matrix.org" + } + }, + isError = addressState is RoomAddressState.Invalid, + onValueChange = onAddressChange, + singleLine = true, + ) +} + +@PreviewsDayNight +@Composable +internal fun JoinRoomByAddressViewPreview( + @PreviewParameter(JoinRoomByAddressStateProvider::class) state: JoinRoomByAddressState +) = ElementPreview { + JoinRoomByAddressView(state = state) +} + diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt index 58edb9d00e..b20eb7cf3b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -55,6 +55,7 @@ class CreateRoomRootNode @AssistedInject constructor( onOpenDM = { navigator.onOpenRoom(it.toRoomIdOrAlias()) }, + onJoinByAddressClick = navigator::onShowJoinRoomByAddress, onInviteFriendsClick = { invitePeople(activity) } ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 7eb2f49511..c24010942e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -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_mention, + text = "Join room by address", + onClick = onJoinByAddressClick, + ) + } if (state.userListState.recentDirectRooms.isNotEmpty()) { item { ListSectionHeader( @@ -230,6 +240,7 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv onCloseClick = {}, onNewRoomClick = {}, onOpenDM = {}, + onJoinByAddressClick = {}, onInviteFriendsClick = {}, ) }