From 5b8690d32e7cf5132d170700fc60da79d5e46eea Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 8 Apr 2024 21:18:25 +0200 Subject: [PATCH] Room navigation : refactor Invites so we can use it in other places --- .../impl/DefaultInviteListEntryPoint.kt | 1 + .../impl/{ => invitelist}/InviteListEvents.kt | 10 +- .../impl/{ => invitelist}/InviteListNode.kt | 5 +- .../{ => invitelist}/InviteListPresenter.kt | 89 +++---------- .../impl/{ => invitelist}/InviteListState.kt | 16 +-- .../InviteListStateProvider.kt | 37 +++--- .../impl/{ => invitelist}/InviteListView.kt | 71 ++--------- .../response/AcceptDeclineInviteEvents.kt | 26 ++++ .../impl/response/AcceptDeclineInviteNode.kt | 46 +++++++ .../response/AcceptDeclineInvitePresenter.kt | 120 ++++++++++++++++++ .../impl/response/AcceptDeclineInviteState.kt | 34 +++++ .../AcceptDeclineInviteStateProvider.kt | 59 +++++++++ .../impl/response/AcceptDeclineInviteView.kt | 111 ++++++++++++++++ .../invite/impl/InviteListPresenterTests.kt | 3 + 14 files changed, 463 insertions(+), 165 deletions(-) rename features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/{ => invitelist}/InviteListEvents.kt (73%) rename features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/{ => invitelist}/InviteListNode.kt (93%) rename features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/{ => invitelist}/InviteListPresenter.kt (56%) rename features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/{ => invitelist}/InviteListState.kt (61%) rename features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/{ => invitelist}/InviteListStateProvider.kt (64%) rename features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/{ => invitelist}/InviteListView.kt (65%) create mode 100644 features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteEvents.kt create mode 100644 features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteNode.kt create mode 100644 features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt create mode 100644 features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteState.kt create mode 100644 features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteStateProvider.kt create mode 100644 features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt index 03e7a57dae..5e464a79bc 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt @@ -21,6 +21,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.invite.api.InviteListEntryPoint +import io.element.android.features.invite.impl.invitelist.InviteListNode import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.AppScope import javax.inject.Inject diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt similarity index 73% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListEvents.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt index 7bf58aba7a..f4ba30844a 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListEvents.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,17 +14,11 @@ * limitations under the License. */ -package io.element.android.features.invite.impl +package io.element.android.features.invite.impl.invitelist import io.element.android.features.invite.impl.model.InviteListInviteSummary sealed interface InviteListEvents { data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents - - data object ConfirmDeclineInvite : InviteListEvents - data object CancelDeclineInvite : InviteListEvents - - data object DismissAcceptError : InviteListEvents - data object DismissDeclineError : InviteListEvents } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt similarity index 93% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListNode.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt index 1f3236e916..53c5b31095 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListNode.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invite.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -50,6 +50,7 @@ class InviteListNode @AssistedInject constructor( state = state, onBackClicked = ::onBackClicked, onInviteAccepted = ::onInviteAccepted, + onInviteDeclined = {} ) } } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListPresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt similarity index 56% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListPresenter.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt index f616450d25..a46f135aa3 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListPresenter.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,43 +14,35 @@ * limitations under the License. */ -package io.element.android.features.invite.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.impl.model.InviteListInviteSummary import io.element.android.features.invite.impl.model.InviteSender -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.invite.impl.response.AcceptDeclineInviteEvents +import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter +import io.element.android.features.invite.impl.response.InviteData import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.roomlist.RoomSummary -import io.element.android.libraries.push.api.notifications.NotificationDrawerManager -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import javax.inject.Inject class InviteListPresenter @Inject constructor( private val client: MatrixClient, private val store: SeenInvitesStore, - private val analyticsService: AnalyticsService, - private val notificationDrawerManager: NotificationDrawerManager, + private val acceptDeclineInvitePresenter: AcceptDeclineInvitePresenter, ) : Presenter { @Composable override fun present(): InviteListState { @@ -75,40 +67,20 @@ class InviteListPresenter @Inject constructor( ) } - val localCoroutineScope = rememberCoroutineScope() - val acceptedAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } - val declinedAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } - val decliningInvite: MutableState = remember { mutableStateOf(null) } + val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() fun handleEvent(event: InviteListEvents) { when (event) { is InviteListEvents.AcceptInvite -> { - acceptedAction.value = AsyncData.Uninitialized - localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData()) + ) } is InviteListEvents.DeclineInvite -> { - decliningInvite.value = event.invite - } - - is InviteListEvents.ConfirmDeclineInvite -> { - declinedAction.value = AsyncData.Uninitialized - decliningInvite.value?.let { - localCoroutineScope.declineInvite(it.roomId, declinedAction) - } - decliningInvite.value = null - } - - is InviteListEvents.CancelDeclineInvite -> { - decliningInvite.value = null - } - - is InviteListEvents.DismissAcceptError -> { - acceptedAction.value = AsyncData.Uninitialized - } - - is InviteListEvents.DismissDeclineError -> { - declinedAction.value = AsyncData.Uninitialized + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData()) + ) } } } @@ -124,38 +96,11 @@ class InviteListPresenter @Inject constructor( return InviteListState( inviteList = inviteList, - declineConfirmationDialog = decliningInvite.value?.let { - InviteDeclineConfirmationDialog.Visible( - isDirect = it.isDirect, - name = it.roomName, - ) - } ?: InviteDeclineConfirmationDialog.Hidden, - acceptedAction = acceptedAction.value, - declinedAction = declinedAction.value, + acceptDeclineInviteState = acceptDeclineInviteState, eventSink = ::handleEvent ) } - private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch { - suspend { - client.getRoom(roomId)?.use { - it.join().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) - analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) - } - roomId - }.runCatchingUpdatingState(acceptedAction) - } - - private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch { - suspend { - client.getRoom(roomId)?.use { - it.leave().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) - }.let { } - }.runCatchingUpdatingState(declinedAction) - } - private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run { val i = inviter val avatarData = if (isDirect && i != null) { @@ -203,4 +148,10 @@ class InviteListPresenter @Inject constructor( }, ) } + + private fun InviteListInviteSummary.toInviteData() = InviteData( + roomId = roomId, + roomName = roomName, + isDirect = isDirect, + ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListState.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt similarity index 61% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListState.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt index 42d73de547..60d8778c33 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListState.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,24 +14,16 @@ * limitations under the License. */ -package io.element.android.features.invite.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.runtime.Immutable +import io.element.android.features.invite.impl.response.AcceptDeclineInviteState import io.element.android.features.invite.impl.model.InviteListInviteSummary -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.collections.immutable.ImmutableList @Immutable data class InviteListState( val inviteList: ImmutableList, - val declineConfirmationDialog: InviteDeclineConfirmationDialog, - val acceptedAction: AsyncData, - val declinedAction: AsyncData, + val acceptDeclineInviteState: AcceptDeclineInviteState, val eventSink: (InviteListEvents) -> Unit ) - -sealed interface InviteDeclineConfirmationDialog { - data object Hidden : InviteDeclineConfirmationDialog - data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog -} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt similarity index 64% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListStateProvider.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt index 5f1d5dcc0f..11902e4673 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListStateProvider.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,35 +14,40 @@ * limitations under the License. */ -package io.element.android.features.invite.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.invite.impl.model.InviteListInviteSummary import io.element.android.features.invite.impl.model.InviteSender -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.invite.impl.response.AcceptDeclineInviteState +import io.element.android.features.invite.impl.response.AcceptDeclineInviteStateProvider +import io.element.android.features.invite.impl.response.anAcceptDeclineInviteState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf open class InviteListStateProvider : PreviewParameterProvider { + + private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider() + override val values: Sequence get() = sequenceOf( - aInviteListState(), - aInviteListState().copy(inviteList = persistentListOf()), - aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(true, "Alice")), - aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(false, "Some Room")), - aInviteListState().copy(acceptedAction = AsyncData.Failure(Throwable("Whoops"))), - aInviteListState().copy(declinedAction = AsyncData.Failure(Throwable("Whoops"))), - ) + anInviteListState(), + anInviteListState(inviteList = persistentListOf()), + ) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState -> + anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState) + } } -internal fun aInviteListState() = InviteListState( - inviteList = aInviteListInviteSummaryList(), - declineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden, - acceptedAction = AsyncData.Uninitialized, - declinedAction = AsyncData.Uninitialized, - eventSink = {}, +internal fun anInviteListState( + inviteList: ImmutableList = aInviteListInviteSummaryList(), + acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + eventSink: (InviteListEvents) -> Unit = {} +) = InviteListState( + inviteList = inviteList, + acceptDeclineInviteState = acceptDeclineInviteState, + eventSink = eventSink, ) internal fun aInviteListInviteSummaryList(): ImmutableList { diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt similarity index 65% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListView.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt index 34da18f02f..2ad75e43eb 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/InviteListView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invite.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -27,20 +27,16 @@ import androidx.compose.foundation.lazy.itemsIndexed 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.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.features.invite.impl.R import io.element.android.features.invite.impl.components.InviteSummaryRow -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.invite.impl.response.AcceptDeclineInviteView import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.aliasScreenTitle @@ -56,61 +52,19 @@ fun InviteListView( state: InviteListState, onBackClicked: () -> Unit, onInviteAccepted: (RoomId) -> Unit, + onInviteDeclined: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { - if (state.acceptedAction is AsyncData.Success) { - val latestOnInviteAccepted by rememberUpdatedState(onInviteAccepted) - LaunchedEffect(state.acceptedAction) { - latestOnInviteAccepted(state.acceptedAction.data) - } - } - InviteListContent( state = state, modifier = modifier, onBackClicked = onBackClicked, ) - - if (state.declineConfirmationDialog is InviteDeclineConfirmationDialog.Visible) { - val contentResource = if (state.declineConfirmationDialog.isDirect) { - R.string.screen_invites_decline_direct_chat_message - } else { - R.string.screen_invites_decline_chat_message - } - - val titleResource = if (state.declineConfirmationDialog.isDirect) { - R.string.screen_invites_decline_direct_chat_title - } else { - R.string.screen_invites_decline_chat_title - } - - ConfirmationDialog( - content = stringResource(contentResource, state.declineConfirmationDialog.name), - title = stringResource(titleResource), - submitText = stringResource(CommonStrings.action_decline), - cancelText = stringResource(CommonStrings.action_cancel), - onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) }, - onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) } - ) - } - - if (state.acceptedAction is AsyncData.Failure) { - ErrorDialog( - content = stringResource(CommonStrings.error_unknown), - title = stringResource(CommonStrings.common_error), - submitText = stringResource(CommonStrings.action_ok), - onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) } - ) - } - - if (state.declinedAction is AsyncData.Failure) { - ErrorDialog( - content = stringResource(CommonStrings.error_unknown), - title = stringResource(CommonStrings.common_error), - submitText = stringResource(CommonStrings.action_ok), - onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) } - ) - } + AcceptDeclineInviteView( + state = state.acceptDeclineInviteState, + onInviteAccepted = onInviteAccepted, + onInviteDeclined = onInviteDeclined, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -138,8 +92,8 @@ private fun InviteListContent( content = { padding -> Column( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) { if (state.inviteList.isEmpty()) { Spacer(Modifier.size(80.dp)) @@ -181,5 +135,6 @@ internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::cl state = state, onBackClicked = {}, onInviteAccepted = {}, + onInviteDeclined = {}, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteEvents.kt new file mode 100644 index 0000000000..17d60cca37 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteEvents.kt @@ -0,0 +1,26 @@ +/* + * 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.features.invite.impl.response + +sealed interface AcceptDeclineInviteEvents { + data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents + data class DeclineInvite(val invite: InviteData) : AcceptDeclineInviteEvents + data object ConfirmDeclineInvite : AcceptDeclineInviteEvents + data object CancelDeclineInvite : AcceptDeclineInviteEvents + data object DismissAcceptError : AcceptDeclineInviteEvents + data object DismissDeclineError : AcceptDeclineInviteEvents +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteNode.kt new file mode 100644 index 0000000000..4718b38881 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteNode.kt @@ -0,0 +1,46 @@ +/* + * 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.features.invite.impl.response + +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 dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class AcceptDeclineInviteNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AcceptDeclineInvitePresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AcceptDeclineInviteView( + state = state, + onInviteAccepted = {}, + onInviteDeclined = {}, + modifier = modifier + ) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt new file mode 100644 index 0000000000..38302377f1 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt @@ -0,0 +1,120 @@ +/* + * 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.features.invite.impl.response + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.architecture.AsyncAction +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.RoomId +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class AcceptDeclineInvitePresenter @Inject constructor( + private val client: MatrixClient, + private val analyticsService: AnalyticsService, + private val notificationDrawerManager: NotificationDrawerManager, +) : Presenter { + + @Composable + override fun present(): AcceptDeclineInviteState { + + val localCoroutineScope = rememberCoroutineScope() + val acceptedAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val declinedAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + var currentInvite by remember { + mutableStateOf>(Optional.empty()) + } + + fun handleEvents(event: AcceptDeclineInviteEvents) { + when (event) { + is AcceptDeclineInviteEvents.AcceptInvite -> { + currentInvite = Optional.of(event.invite) + localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + } + + is AcceptDeclineInviteEvents.DeclineInvite -> { + currentInvite = Optional.of(event.invite) + declinedAction.value = AsyncAction.Confirming + } + + is AcceptDeclineInviteEvents.ConfirmDeclineInvite -> { + declinedAction.value = AsyncAction.Uninitialized + currentInvite.getOrNull()?.let { + localCoroutineScope.declineInvite(it.roomId, declinedAction) + } + currentInvite = Optional.empty() + } + + is AcceptDeclineInviteEvents.CancelDeclineInvite -> { + currentInvite = Optional.empty() + declinedAction.value = AsyncAction.Uninitialized + } + + is AcceptDeclineInviteEvents.DismissAcceptError -> { + acceptedAction.value = AsyncAction.Uninitialized + } + + is AcceptDeclineInviteEvents.DismissDeclineError -> { + declinedAction.value = AsyncAction.Uninitialized + } + } + } + + return AcceptDeclineInviteState( + invite = currentInvite, + acceptAction = acceptedAction.value, + declineAction = declinedAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch { + suspend { + client.getRoom(roomId)?.use { + it.join().getOrThrow() + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) + } + roomId + }.runCatchingUpdatingState(acceptedAction) + } + + private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch { + suspend { + client.getRoom(roomId)?.use { + it.leave().getOrThrow() + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + } + roomId + }.runCatchingUpdatingState(declinedAction) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteState.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteState.kt new file mode 100644 index 0000000000..11b960f57b --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteState.kt @@ -0,0 +1,34 @@ +/* + * 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.features.invite.impl.response + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import java.util.Optional + +data class AcceptDeclineInviteState( + val invite: Optional, + val acceptAction: AsyncAction, + val declineAction: AsyncAction, + val eventSink: (AcceptDeclineInviteEvents) -> Unit +) + +data class InviteData( + val roomId: RoomId, + val roomName: String, + val isDirect: Boolean, +) diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteStateProvider.kt new file mode 100644 index 0000000000..710c8b7538 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteStateProvider.kt @@ -0,0 +1,59 @@ +/* + * 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.features.invite.impl.response + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import java.util.Optional + +open class AcceptDeclineInviteStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAcceptDeclineInviteState(), + anAcceptDeclineInviteState( + invite = Optional.of( + InviteData(RoomId(""), isDirect = true, roomName = "Alice"), + ), + declineAction = AsyncAction.Confirming, + ), + anAcceptDeclineInviteState( + invite = Optional.of( + InviteData(RoomId(""), isDirect = false, roomName = "Some room"), + ), + declineAction = AsyncAction.Confirming, + ), + anAcceptDeclineInviteState( + acceptAction = AsyncAction.Failure(Throwable("Whoops")), + ), + anAcceptDeclineInviteState( + declineAction = AsyncAction.Failure(Throwable("Whoops")), + ), + ) +} + +fun anAcceptDeclineInviteState( + invite: Optional = Optional.empty(), + acceptAction: AsyncAction = AsyncAction.Uninitialized, + declineAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (AcceptDeclineInviteEvents) -> Unit = {} +) = AcceptDeclineInviteState( + invite = invite, + acceptAction = acceptAction, + declineAction = declineAction, + eventSink = eventSink, +) diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt new file mode 100644 index 0000000000..a214fed4a2 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt @@ -0,0 +1,111 @@ +/* + * 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.features.invite.impl.response + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.invite.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.jvm.optionals.getOrNull + +@Composable +fun AcceptDeclineInviteView( + state: AcceptDeclineInviteState, + onInviteAccepted: (RoomId) -> Unit, + onInviteDeclined: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AsyncActionView( + async = state.acceptAction, + onSuccess = onInviteAccepted, + onErrorDismiss = { + state.eventSink(AcceptDeclineInviteEvents.DismissAcceptError) + }, + ) + AsyncActionView( + async = state.declineAction, + onSuccess = onInviteDeclined, + onErrorDismiss = { + state.eventSink(AcceptDeclineInviteEvents.DismissDeclineError) + }, + confirmationDialog = { + val invite = state.invite.getOrNull() + if (invite != null) { + DeclineConfirmationDialog( + invite = invite, + onConfirmClicked = { + state.eventSink(AcceptDeclineInviteEvents.ConfirmDeclineInvite) + }, + onDismissClicked = { + state.eventSink(AcceptDeclineInviteEvents.CancelDeclineInvite) + } + ) + } + } + ) + } +} + +@Composable +private fun DeclineConfirmationDialog( + invite: InviteData, + onConfirmClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier +) { + val contentResource = if (invite.isDirect) { + R.string.screen_invites_decline_direct_chat_message + } else { + R.string.screen_invites_decline_chat_message + } + + val titleResource = if (invite.isDirect) { + R.string.screen_invites_decline_direct_chat_title + } else { + R.string.screen_invites_decline_chat_title + } + + ConfirmationDialog( + modifier = modifier, + content = stringResource(contentResource, invite.roomName), + title = stringResource(titleResource), + submitText = stringResource(CommonStrings.action_decline), + cancelText = stringResource(CommonStrings.action_cancel), + onSubmitClicked = onConfirmClicked, + onDismiss = onDismissClicked, + ) +} + +@PreviewLightDark +@Composable +internal fun AcceptDeclineInviteViewLightPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) = + ElementPreview { + AcceptDeclineInviteView( + state = state, + onInviteAccepted = {}, + onInviteDeclined = {}, + ) + } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/InviteListPresenterTests.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/InviteListPresenterTests.kt index b9acd5eb0b..8e192e01ed 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/InviteListPresenterTests.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/InviteListPresenterTests.kt @@ -22,6 +22,9 @@ import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.impl.invitelist.InviteListEvents +import io.element.android.features.invite.impl.invitelist.InviteListPresenter +import io.element.android.features.invite.impl.invitelist.InviteListState import io.element.android.features.invite.test.FakeSeenInvitesStore import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData