Room navigation : refactor Invites so we can use it in other places

This commit is contained in:
ganfra 2024-04-08 21:18:25 +02:00
parent 28361be6e8
commit 5b8690d32e
14 changed files with 463 additions and 165 deletions

View file

@ -21,6 +21,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invite.api.InviteListEntryPoint 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.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import javax.inject.Inject import javax.inject.Inject

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,17 +14,11 @@
* limitations under the License. * 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 import io.element.android.features.invite.impl.model.InviteListInviteSummary
sealed interface InviteListEvents { sealed interface InviteListEvents {
data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
data class DeclineInvite(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
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -50,6 +50,7 @@ class InviteListNode @AssistedInject constructor(
state = state, state = state,
onBackClicked = ::onBackClicked, onBackClicked = ::onBackClicked,
onInviteAccepted = ::onInviteAccepted, onInviteAccepted = ::onInviteAccepted,
onInviteDeclined = {}
) )
} }
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,43 +14,35 @@
* limitations under the License. * 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.api.SeenInvitesStore
import io.element.android.features.invite.impl.model.InviteListInviteSummary import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.features.invite.impl.model.InviteSender 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.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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary 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.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class InviteListPresenter @Inject constructor( class InviteListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val store: SeenInvitesStore, private val store: SeenInvitesStore,
private val analyticsService: AnalyticsService, private val acceptDeclineInvitePresenter: AcceptDeclineInvitePresenter,
private val notificationDrawerManager: NotificationDrawerManager,
) : Presenter<InviteListState> { ) : Presenter<InviteListState> {
@Composable @Composable
override fun present(): InviteListState { override fun present(): InviteListState {
@ -75,40 +67,20 @@ class InviteListPresenter @Inject constructor(
) )
} }
val localCoroutineScope = rememberCoroutineScope() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
val acceptedAction: MutableState<AsyncData<RoomId>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val declinedAction: MutableState<AsyncData<Unit>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val decliningInvite: MutableState<InviteListInviteSummary?> = remember { mutableStateOf(null) }
fun handleEvent(event: InviteListEvents) { fun handleEvent(event: InviteListEvents) {
when (event) { when (event) {
is InviteListEvents.AcceptInvite -> { is InviteListEvents.AcceptInvite -> {
acceptedAction.value = AsyncData.Uninitialized acceptDeclineInviteState.eventSink(
localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData())
)
} }
is InviteListEvents.DeclineInvite -> { is InviteListEvents.DeclineInvite -> {
decliningInvite.value = event.invite acceptDeclineInviteState.eventSink(
} AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData())
)
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
} }
} }
} }
@ -124,38 +96,11 @@ class InviteListPresenter @Inject constructor(
return InviteListState( return InviteListState(
inviteList = inviteList, inviteList = inviteList,
declineConfirmationDialog = decliningInvite.value?.let { acceptDeclineInviteState = acceptDeclineInviteState,
InviteDeclineConfirmationDialog.Visible(
isDirect = it.isDirect,
name = it.roomName,
)
} ?: InviteDeclineConfirmationDialog.Hidden,
acceptedAction = acceptedAction.value,
declinedAction = declinedAction.value,
eventSink = ::handleEvent eventSink = ::handleEvent
) )
} }
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncData<RoomId>>) = 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<AsyncData<Unit>>) = 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 { private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
val i = inviter val i = inviter
val avatarData = if (isDirect && i != null) { 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,
)
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,24 +14,16 @@
* limitations under the License. * 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 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.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 import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class InviteListState( data class InviteListState(
val inviteList: ImmutableList<InviteListInviteSummary>, val inviteList: ImmutableList<InviteListInviteSummary>,
val declineConfirmationDialog: InviteDeclineConfirmationDialog, val acceptDeclineInviteState: AcceptDeclineInviteState,
val acceptedAction: AsyncData<RoomId>,
val declinedAction: AsyncData<Unit>,
val eventSink: (InviteListEvents) -> Unit val eventSink: (InviteListEvents) -> Unit
) )
sealed interface InviteDeclineConfirmationDialog {
data object Hidden : InviteDeclineConfirmationDialog
data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog
}

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,35 +14,40 @@
* limitations under the License. * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.impl.model.InviteListInviteSummary import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.features.invite.impl.model.InviteSender 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class InviteListStateProvider : PreviewParameterProvider<InviteListState> { open class InviteListStateProvider : PreviewParameterProvider<InviteListState> {
private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider()
override val values: Sequence<InviteListState> override val values: Sequence<InviteListState>
get() = sequenceOf( get() = sequenceOf(
aInviteListState(), anInviteListState(),
aInviteListState().copy(inviteList = persistentListOf()), anInviteListState(inviteList = persistentListOf()),
aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(true, "Alice")), ) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState ->
aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(false, "Some Room")), anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState)
aInviteListState().copy(acceptedAction = AsyncData.Failure(Throwable("Whoops"))), }
aInviteListState().copy(declinedAction = AsyncData.Failure(Throwable("Whoops"))),
)
} }
internal fun aInviteListState() = InviteListState( internal fun anInviteListState(
inviteList = aInviteListInviteSummaryList(), inviteList: ImmutableList<InviteListInviteSummary> = aInviteListInviteSummaryList(),
declineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden, acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
acceptedAction = AsyncData.Uninitialized, eventSink: (InviteListEvents) -> Unit = {}
declinedAction = AsyncData.Uninitialized, ) = InviteListState(
eventSink = {}, inviteList = inviteList,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
) )
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> { internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> {

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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.Column
import androidx.compose.foundation.layout.Spacer 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
@ -56,61 +52,19 @@ fun InviteListView(
state: InviteListState, state: InviteListState,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onInviteAccepted: (RoomId) -> Unit, onInviteAccepted: (RoomId) -> Unit,
onInviteDeclined: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.acceptedAction is AsyncData.Success) {
val latestOnInviteAccepted by rememberUpdatedState(onInviteAccepted)
LaunchedEffect(state.acceptedAction) {
latestOnInviteAccepted(state.acceptedAction.data)
}
}
InviteListContent( InviteListContent(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
AcceptDeclineInviteView(
if (state.declineConfirmationDialog is InviteDeclineConfirmationDialog.Visible) { state = state.acceptDeclineInviteState,
val contentResource = if (state.declineConfirmationDialog.isDirect) { onInviteAccepted = onInviteAccepted,
R.string.screen_invites_decline_direct_chat_message onInviteDeclined = onInviteDeclined,
} 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) }
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -138,8 +92,8 @@ private fun InviteListContent(
content = { padding -> content = { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
if (state.inviteList.isEmpty()) { if (state.inviteList.isEmpty()) {
Spacer(Modifier.size(80.dp)) Spacer(Modifier.size(80.dp))
@ -181,5 +135,6 @@ internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::cl
state = state, state = state,
onBackClicked = {}, onBackClicked = {},
onInviteAccepted = {}, onInviteAccepted = {},
onInviteDeclined = {},
) )
} }

View file

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

View file

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

View file

@ -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<AcceptDeclineInviteState> {
@Composable
override fun present(): AcceptDeclineInviteState {
val localCoroutineScope = rememberCoroutineScope()
val acceptedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val declinedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var currentInvite by remember {
mutableStateOf<Optional<InviteData>>(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<AsyncAction<RoomId>>) = 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<AsyncAction<RoomId>>) = launch {
suspend {
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
}
roomId
}.runCatchingUpdatingState(declinedAction)
}
}

View file

@ -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<InviteData>,
val acceptAction: AsyncAction<RoomId>,
val declineAction: AsyncAction<RoomId>,
val eventSink: (AcceptDeclineInviteEvents) -> Unit
)
data class InviteData(
val roomId: RoomId,
val roomName: String,
val isDirect: Boolean,
)

View file

@ -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<AcceptDeclineInviteState> {
override val values: Sequence<AcceptDeclineInviteState>
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<InviteData> = Optional.empty(),
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState(
invite = invite,
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)

View file

@ -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 = {},
)
}

View file

@ -22,6 +22,9 @@ import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore 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.features.invite.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData