feat(join by alias) : introduce the JoinRoomByAddress

This commit is contained in:
ganfra
2025-02-20 20:59:42 +01:00
parent 12c0465d2a
commit f1c5fa53e8
10 changed files with 355 additions and 1 deletions

View File

@@ -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<NavTarget>,
private val overlay: Overlay<NavTarget>,
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()
}
}

View File

@@ -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<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomIdOrAlias) }
}
@@ -67,11 +73,17 @@ class CreateRoomFlowNode @AssistedInject constructor(
NavTarget.NewRoom -> {
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

@@ -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,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<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,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<JoinRoomByAddressState> {
@AssistedFactory
interface Factory {
fun create(navigator: CreateRoomNavigator): JoinRoomByAddressPresenter
}
@Composable
override fun present(): JoinRoomByAddressState {
var address by remember { mutableStateOf("") }
var addressState by remember { mutableStateOf<RoomAddressState>(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))
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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<JoinRoomByAddressState> {
override val values: Sequence<JoinRoomByAddressState>
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 = {}
)

View File

@@ -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)
}

View File

@@ -55,6 +55,7 @@ class CreateRoomRootNode @AssistedInject constructor(
onOpenDM = {
navigator.onOpenRoom(it.toRoomIdOrAlias())
},
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_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 = {},
)
}