diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index a752920800..d0fc830892 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -36,6 +36,7 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode +import io.element.android.appnav.room.resolver.RoomAliasResolverNode import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.BackstackView @@ -43,7 +44,6 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient @@ -89,10 +89,10 @@ class RoomFlowNode @AssistedInject constructor( data object Loading : NavTarget @Parcelize - data class JoinRoom(val roomId: RoomId) : NavTarget + data class Resolving(val roomAlias: RoomAlias) : NavTarget @Parcelize - data class RoomAliasError(val roomAlias: RoomAlias) : NavTarget + data class JoinRoom(val roomId: RoomId) : NavTarget @Parcelize data class JoinedRoom(val roomId: RoomId) : NavTarget @@ -107,14 +107,7 @@ class RoomFlowNode @AssistedInject constructor( lifecycleScope.launch { when (val i = inputs.roomIdOrAlias) { is RoomIdOrAlias.Alias -> { - client.resolveRoomAlias(i.roomAlias) - .onFailure { - Timber.e(it, "Failed to resolve room alias") - backstack.newRoot(NavTarget.RoomAliasError(i.roomAlias)) - } - .onSuccess { - subscribeToRoomInfoFlow(it) - } + backstack.newRoot(NavTarget.Resolving(i.roomAlias)) } is RoomIdOrAlias.Id -> { subscribeToRoomInfoFlow(i.roomId) @@ -125,16 +118,17 @@ class RoomFlowNode @AssistedInject constructor( private fun subscribeToRoomInfoFlow(roomId: RoomId) { client.getRoomInfoFlow( - roomId - ).onEach { roomInfo -> - Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}") - val info = roomInfo.getOrNull() - if (info?.currentUserMembership == CurrentUserMembership.JOINED) { - backstack.newRoot(NavTarget.JoinedRoom(roomId)) - } else { - backstack.newRoot(NavTarget.JoinRoom(roomId)) + roomId = roomId + ) + .onEach { roomInfo -> + Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}") + val info = roomInfo.getOrNull() + if (info?.currentUserMembership == CurrentUserMembership.JOINED) { + backstack.newRoot(NavTarget.JoinedRoom(roomId)) + } else { + backstack.newRoot(NavTarget.JoinRoom(roomId)) + } } - } .launchIn(lifecycleScope) // When leaving the room from this session only, navigate up. @@ -148,7 +142,16 @@ class RoomFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Loading -> loadingNode(buildContext) + is NavTarget.Loading -> loadingNode(buildContext) + is NavTarget.Resolving -> { + val callback = object : RoomAliasResolverNode.Callback { + override fun onAliasResolved(roomId: RoomId) { + backstack.newRoot(NavTarget.JoinRoom(roomId)) + } + } + val params = RoomAliasResolverNode.Inputs(navTarget.roomAlias) + createNode(buildContext, listOf(callback, params)) + } is NavTarget.JoinRoom -> { val inputs = JoinRoomEntryPoint.Inputs( roomId = navTarget.roomId, @@ -162,7 +165,6 @@ class RoomFlowNode @AssistedInject constructor( val inputs = JoinedRoomFlowNode.Inputs(navTarget.roomId, initialElement = inputs.initialElement) createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) } - is NavTarget.RoomAliasError -> roomAliasErrorNode(buildContext, navTarget.roomAlias) } } @@ -172,15 +174,6 @@ class RoomFlowNode @AssistedInject constructor( } } - private fun roomAliasErrorNode(buildContext: BuildContext, roomAlias: RoomAlias) = node(buildContext) { - Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { - AsyncFailure( - throwable = Exception("Unable to resolve alias ${roomAlias.value}"), - onRetry = { resolveRoomId() }, - ) - } - } - @Composable override fun View(modifier: Modifier) { BackstackView(transitionHandler = JumpToEndTransitionHandler()) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/ResolveRoomModule.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/ResolveRoomModule.kt new file mode 100644 index 0000000000..687fefc5c5 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/ResolveRoomModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias + +@Module +@ContributesTo(SessionScope::class) +object ResolveRoomModule { + @Provides + fun providesJoinRoomPresenterFactory( + client: MatrixClient, + ): RoomAliasResolverPresenter.Factory { + return object : RoomAliasResolverPresenter.Factory { + override fun create(roomAlias: RoomAlias): RoomAliasResolverPresenter { + return RoomAliasResolverPresenter( + roomAlias = roomAlias, + matrixClient = client, + ) + } + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverEvents.kt new file mode 100644 index 0000000000..1eb3c8ffe9 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +sealed interface RoomAliasResolverEvents { + data object Retry : RoomAliasResolverEvents +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverNode.kt new file mode 100644 index 0000000000..c429ec1a95 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverNode.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +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.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesNode(SessionScope::class) +class RoomAliasResolverNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomAliasResolverPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val roomAlias: RoomAlias + ) : NodeInputs + + private val inputs = inputs() + + private val presenter = presenterFactory.create( + inputs.roomAlias + ) + + interface Callback : Plugin { + fun onAliasResolved(roomId: RoomId) + } + + private fun onAliasResolved(roomId: RoomId) { + plugins().forEach { it.onAliasResolved(roomId) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomAliasResolverView( + state = state, + onAliasResolved = ::onAliasResolved, + onBackPressed = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverPresenter.kt new file mode 100644 index 0000000000..5e9e3382d5 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverPresenter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +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.RoomId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class RoomAliasResolverPresenter @AssistedInject constructor( + @Assisted private val roomAlias: RoomAlias, + private val matrixClient: MatrixClient, +) : Presenter { + interface Factory { + fun create( + roomAlias: RoomAlias, + ): RoomAliasResolverPresenter + } + + @Composable + override fun present(): RoomAliasResolverState { + val coroutineScope = rememberCoroutineScope() + val resolveState: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + LaunchedEffect(Unit) { + resolveAlias(resolveState) + } + + fun handleEvents(event: RoomAliasResolverEvents) { + when (event) { + RoomAliasResolverEvents.Retry -> coroutineScope.resolveAlias(resolveState) + } + } + + return RoomAliasResolverState( + roomAlias = roomAlias, + resolveState = resolveState.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { + suspend { + matrixClient.resolveRoomAlias(roomAlias).getOrThrow() + }.runCatchingUpdatingState(resolveState) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverState.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverState.kt new file mode 100644 index 0000000000..4f800a76cd --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +@Immutable +data class RoomAliasResolverState( + val roomAlias: RoomAlias, + val resolveState: AsyncData, + val eventSink: (RoomAliasResolverEvents) -> Unit +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverStateProvider.kt new file mode 100644 index 0000000000..9584bb21b8 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverStateProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +open class RoomAliasResolverStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomAliasResolverState(), + aRoomAliasResolverState( + resolveState = AsyncData.Loading(), + ), + aRoomAliasResolverState( + resolveState = AsyncData.Failure(Exception("Error")), + ), + ) +} + +fun aRoomAliasResolverState( + roomAlias: RoomAlias = A_ROOM_ALIAS, + resolveState: AsyncData = AsyncData.Uninitialized, + eventSink: (RoomAliasResolverEvents) -> Unit = {} +) = RoomAliasResolverState( + roomAlias = roomAlias, + resolveState = resolveState, + eventSink = eventSink, +) + +private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org") diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverView.kt new file mode 100644 index 0000000000..e208119a16 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/resolver/RoomAliasResolverView.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room.resolver + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +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.ui.strings.CommonStrings + +@Composable +fun RoomAliasResolverView( + state: RoomAliasResolverState, + onBackPressed: () -> Unit, + onAliasResolved: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnAliasResolved by rememberUpdatedState(onAliasResolved) + LaunchedEffect(state.resolveState) { + if (state.resolveState is AsyncData.Success) { + latestOnAliasResolved(state.resolveState.data) + } + } + + HeaderFooterPage( + modifier = modifier, + paddingValues = PaddingValues(16.dp), + topBar = { + RoomAliasResolverTopBar(onBackClicked = onBackPressed) + }, + content = { + RoomAliasResolverContent(state = state) + }, + footer = { + RoomAliasResolverFooter( + state = state, + ) + } + ) +} + +@Composable +private fun RoomAliasResolverFooter( + state: RoomAliasResolverState, + modifier: Modifier = Modifier, +) { + when (state.resolveState) { + is AsyncData.Failure -> { + Button( + text = stringResource(CommonStrings.action_retry), + onClick = { + state.eventSink(RoomAliasResolverEvents.Retry) + }, + modifier = modifier.fillMaxWidth(), + size = ButtonSize.Medium, + ) + } + is AsyncData.Loading -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } + AsyncData.Uninitialized, + is AsyncData.Success -> Unit + } +} + +@Composable +private fun RoomAliasResolverContent( + state: RoomAliasResolverState, + modifier: Modifier = Modifier, +) { + ContentScaffold( + modifier = modifier, + avatar = { + PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) + }, + title = { + }, + subtitle = { + Title(state.roomAlias.value) + }, + description = { + if (state.resolveState.isFailure()) { + Text( + text = "Failed to resolve room alias", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + } + }, + memberCount = { + } + ) +} + +@Composable +private fun ContentScaffold( + avatar: @Composable () -> Unit, + title: @Composable () -> Unit, + subtitle: @Composable () -> Unit, + modifier: Modifier = Modifier, + description: @Composable (() -> Unit)? = null, + memberCount: @Composable (() -> Unit)? = null, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + avatar() + Spacer(modifier = Modifier.height(16.dp)) + title() + Spacer(modifier = Modifier.height(8.dp)) + subtitle() + Spacer(modifier = Modifier.height(8.dp)) + if (memberCount != null) { + memberCount() + } + Spacer(modifier = Modifier.height(8.dp)) + if (description != null) { + description() + } + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun Title(title: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = title, + style = ElementTheme.typography.fontHeadingMdBold, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomAliasResolverTopBar( + onBackClicked: () -> Unit, +) { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClicked) + }, + title = {}, + ) +} + +@PreviewLightDark +@Composable +internal fun RoomAliasResolverViewPreview(@PreviewParameter(RoomAliasResolverStateProvider::class) state: RoomAliasResolverState) = ElementPreview { + RoomAliasResolverView( + state = state, + onAliasResolved = { }, + onBackPressed = { } + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index b87e1cb343..b7958f792c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -124,6 +124,7 @@ class MessagesNode @AssistedInject constructor( } } is PermalinkData.RoomLink -> { + // TODO Handle click on current Room callback?.onPermalinkClicked(permalink) } is PermalinkData.FallbackLink,