1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2382 Multiplayer | Fix | Handle corner-case scenarios when handling space invite (#1070)

This commit is contained in:
Evgenii Kozlov 2024-04-08 16:45:38 +02:00 committed by GitHub
parent 5a82c78f6a
commit 680f7fde42
Signed by: github
GPG key ID: B5690EEEBB952194
10 changed files with 243 additions and 44 deletions

View file

@ -3,8 +3,10 @@ package com.anytypeio.anytype.di.feature.multiplayer
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.core_utils.di.scope.PerDialog
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.config.UserSettingsRepository
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.SpaceInviteResolver
import com.anytypeio.anytype.domain.workspace.SpaceManager
@ -54,6 +56,8 @@ object RequestJoinSpaceModule {
interface RequestJoinSpaceDependencies : ComponentDependencies {
fun blockRepository(): BlockRepository
fun auth(): AuthRepository
fun settings(): UserSettingsRepository
fun urlBuilder(): UrlBuilder
fun dispatchers(): AppCoroutineDispatchers
fun spaceManager(): SpaceManager

View file

@ -8,18 +8,26 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ext.EMPTY_STRING_VALUE
import com.anytypeio.anytype.core_ui.features.multiplayer.JoinSpaceScreen
import com.anytypeio.anytype.core_ui.foundation.AlertConfig
import com.anytypeio.anytype.core_ui.foundation.BUTTON_SECONDARY
import com.anytypeio.anytype.core_ui.foundation.GRADIENT_TYPE_BLUE
import com.anytypeio.anytype.core_ui.foundation.GenericAlert
import com.anytypeio.anytype.core_ui.foundation.Warning
import com.anytypeio.anytype.core_utils.ext.arg
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.presentation.common.ViewState
import com.anytypeio.anytype.presentation.common.TypedViewState
import com.anytypeio.anytype.presentation.multiplayer.RequestJoinSpaceViewModel
import com.anytypeio.anytype.presentation.multiplayer.RequestJoinSpaceViewModel.ErrorView
import com.anytypeio.anytype.ui.settings.typography
import javax.inject.Inject
@ -42,19 +50,51 @@ class RequestJoinSpaceFragment : BaseBottomSheetComposeFragment() {
setContent {
MaterialTheme(typography = typography) {
when(val state = vm.state.collectAsStateWithLifecycle().value) {
is ViewState.Error -> {
toast(state.error).also { dismiss() }
}
ViewState.Loading -> {
is TypedViewState.Loading -> {
// Render nothing.
}
is ViewState.Success -> {
is TypedViewState.Success -> {
JoinSpaceScreen(
onRequestJoinSpaceClicked = vm::onRequestToJoinClicked,
spaceName = state.data.spaceName,
createdByName = state.data.creatorName
)
}
is TypedViewState.Error -> {
when(val err = state.error) {
is ErrorView.AlreadySpaceMember -> {
Warning(
title = stringResource(id = R.string.multiplayer_already_space_member),
subtitle = EMPTY_STRING_VALUE,
actionButtonText = stringResource(id = R.string.multiplayer_open_space),
cancelButtonText = stringResource(id = R.string.cancel),
onNegativeClick = {
dismiss()
},
onPositiveClick = {
vm.onOpenSpaceClicked(err.space)
}
)
}
is ErrorView.InvalidLink -> {
GenericAlert(
config = AlertConfig.WithOneButton(
title = "This link does not seem to work",
firstButtonText = stringResource(id = R.string.button_okay),
firstButtonType = BUTTON_SECONDARY,
description = EMPTY_STRING_VALUE,
icon = AlertConfig.Icon(
gradient = GRADIENT_TYPE_BLUE,
icon = R.drawable.ic_alert_message
)
),
onFirstButtonClicked = {
dismiss()
}
)
}
}
}
}
LaunchedEffect(Unit) {
vm.toasts.collect { toast(it) }
@ -77,6 +117,12 @@ class RequestJoinSpaceFragment : BaseBottomSheetComposeFragment() {
RequestJoinSpaceViewModel.Command.Toast.RequestSent -> {
toast(getString(R.string.multiplayer_request_sent_toast))
}
RequestJoinSpaceViewModel.Command.Toast.SpaceDeleted -> {
toast(getString(R.string.multiplayer_error_space_deleted))
}
RequestJoinSpaceViewModel.Command.Toast.SpaceNotFound -> {
toast(getString(R.string.multiplayer_error_space_not_found))
}
}
}

View file

@ -44,4 +44,10 @@ enum class SpaceAccessType(val code: Int) {
PRIVATE(0),
DEFAULT(1),
SHARED(2)
}
sealed class SpaceInviteError : Exception() {
class SpaceNotFound : SpaceInviteError()
class SpaceDeleted: SpaceInviteError()
class InvalidInvite: SpaceInviteError()
}

View file

@ -232,17 +232,21 @@ fun Warning(
style = HeadlineHeading,
color = colorResource(R.color.text_primary)
)
Text(
text = subtitle,
modifier = Modifier.padding(
top = 12.dp,
start = 20.dp,
end = 20.dp,
bottom = 10.dp
),
style = BodyCalloutRegular,
color = colorResource(R.color.text_primary)
)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
modifier = Modifier.padding(
top = 12.dp,
start = 20.dp,
end = 20.dp,
bottom = 10.dp
),
style = BodyCalloutRegular,
color = colorResource(R.color.text_primary)
)
} else {
Spacer(modifier = Modifier.height(12.dp))
}
Row(
modifier = Modifier
.padding(

View file

@ -45,7 +45,7 @@ import com.anytypeio.anytype.core_ui.views.HeadlineHeading
@Preview
@Composable
fun AlertWithTwoButtons() {
private fun AlertWithTwoButtons() {
GenericAlert(
onFirstButtonClicked = {},
onSecondButtonClicked = {},
@ -66,7 +66,7 @@ fun AlertWithTwoButtons() {
@Preview
@Composable
fun AlertWithWarningAndTwoButtons() {
private fun AlertWithWarningAndTwoButtons() {
GenericAlert(
onFirstButtonClicked = {},
onSecondButtonClicked = {},
@ -86,8 +86,8 @@ fun AlertWithWarningAndTwoButtons() {
}
@Preview
@Composable
fun AlertWithWarningButton() {
@Composable
private fun AlertWithWarningButton() {
GenericAlert(
onFirstButtonClicked = {},
onSecondButtonClicked = {},
@ -108,7 +108,7 @@ fun AlertWithWarningButton() {
@Preview
@Composable
fun AlertWithMessageButton() {
private fun AlertWithMessageButton() {
GenericAlert(
onFirstButtonClicked = {},
onSecondButtonClicked = {},
@ -139,8 +139,10 @@ fun GenericAlert(
if (icon != null) { AlertIcon(icon) }
Spacer(modifier = Modifier.height(16.dp))
AlertTitle(config.title)
Spacer(modifier = Modifier.height(8.dp))
AlertDescription(config.description)
if (config.withDescription) {
Spacer(modifier = Modifier.height(8.dp))
AlertDescription(config.description)
}
Spacer(modifier = Modifier.height(20.dp))
AlertButtons(
config = config,
@ -332,6 +334,8 @@ sealed class AlertConfig {
abstract val description: String
abstract val icon: Icon?
val withDescription get() = description.isNotEmpty()
data class WithTwoButtons(
override val icon: Icon?,
override val title: String,

View file

@ -0,0 +1,50 @@
package com.anytypeio.anytype.domain.multiplayer
import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVFilterCondition
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.ResultInteractor
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import javax.inject.Inject
class CheckIsUserSpaceMember @Inject constructor(
private val repo: BlockRepository,
private val auth: AuthRepository,
dispatchers: AppCoroutineDispatchers
) : ResultInteractor<SpaceId, Boolean>(dispatchers.io) {
override suspend fun doWork(params: SpaceId): Boolean {
val account = auth.getCurrentAccountId()
val results = repo.searchObjects(
limit = 1,
filters = buildList {
add(
DVFilter(
relation = Relations.LAYOUT,
value = ObjectType.Layout.PARTICIPANT.code.toDouble(),
condition = DVFilterCondition.EQUAL
)
)
add(
DVFilter(
relation = Relations.IDENTITY,
value = account,
condition = DVFilterCondition.EQUAL
)
)
add(
DVFilter(
relation = Relations.SPACE_ID,
value = params.id,
condition = DVFilterCondition.EQUAL
)
)
},
)
return results.isNotEmpty()
}
}

View file

@ -1348,7 +1348,11 @@
<string name="multiplayer_how_to_share_space_step_3">After approving the request, you can choose the access rights for that person.</string>
<string name="multiplayer_delete_space_sharing_link">Delete sharing link</string>
<string name="multiplayer_delete_link">Delete link</string>
<string name="multiplayer_delete_link_description">New members wont be able to join the space. You can generate a new link anytype.</string>
<string name="multiplayer_delete_link_description">New members wont be able to join the space. You can generate a new link anytime.</string>
<string name="multiplayer_already_space_member">You are already a member of this space</string>
<string name="multiplayer_open_space">Open space</string>
<string name="multiplayer_error_space_deleted">Space deleted</string>
<string name="multiplayer_error_space_not_found">Space not found</string>
<!--endregion-->

View file

@ -5,6 +5,7 @@ import com.anytypeio.anytype.core_models.exceptions.AccountIsDeletedException
import com.anytypeio.anytype.core_models.exceptions.LoginException
import com.anytypeio.anytype.core_models.exceptions.MigrationNeededException
import com.anytypeio.anytype.core_models.exceptions.NeedToUpdateApplicationException
import com.anytypeio.anytype.core_models.multiplayer.SpaceInviteError
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.data.auth.exception.AnytypeNeedsUpgradeException
import com.anytypeio.anytype.data.auth.exception.NotFoundObjectException
@ -1827,7 +1828,23 @@ class MiddlewareServiceImplementation @Inject constructor(
val response = Rpc.Space.InviteView.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Space.InviteView.Response.Error.Code.NULL) {
throw Exception(error.description)
when(error.code) {
Rpc.Space.InviteView.Response.Error.Code.NO_SUCH_SPACE -> {
throw SpaceInviteError.SpaceNotFound()
}
Rpc.Space.InviteView.Response.Error.Code.SPACE_IS_DELETED -> {
throw SpaceInviteError.SpaceDeleted()
}
Rpc.Space.InviteView.Response.Error.Code.INVITE_NOT_FOUND -> {
throw SpaceInviteError.InvalidInvite()
}
Rpc.Space.InviteView.Response.Error.Code.INVITE_BAD_SIGNATURE -> {
throw SpaceInviteError.InvalidInvite()
}
else -> {
throw Exception(error.description)
}
}
} else {
return response
}

View file

@ -3,5 +3,11 @@ package com.anytypeio.anytype.presentation.common
sealed class ViewState<out T : Any> {
data class Success<out T : Any>(val data: T) : ViewState<T>()
data class Error(val error: String) : ViewState<Nothing>()
object Loading : ViewState<Nothing>()
data object Loading : ViewState<Nothing>()
}
sealed class TypedViewState<out T : Any, out E: Any> {
data class Success<out T : Any, out E: Any>(val data: T) : TypedViewState<T, E>()
data class Error<out T : Any, out E: Any>(val error: E) : TypedViewState<T, E>()
data object Loading : TypedViewState<Nothing, Nothing>()
}

View file

@ -3,14 +3,20 @@ package com.anytypeio.anytype.presentation.multiplayer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.multiplayer.SpaceInviteError
import com.anytypeio.anytype.core_models.multiplayer.SpaceInviteView
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_utils.ext.msg
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.base.getOrDefault
import com.anytypeio.anytype.domain.multiplayer.CheckIsUserSpaceMember
import com.anytypeio.anytype.domain.multiplayer.GetSpaceInviteView
import com.anytypeio.anytype.domain.multiplayer.SendJoinSpaceRequest
import com.anytypeio.anytype.domain.multiplayer.SpaceInviteResolver
import com.anytypeio.anytype.domain.spaces.SaveCurrentSpace
import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.common.ViewState
import com.anytypeio.anytype.presentation.common.TypedViewState
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -21,10 +27,13 @@ class RequestJoinSpaceViewModel(
private val params: Params,
private val getSpaceInviteView: GetSpaceInviteView,
private val sendJoinSpaceRequest: SendJoinSpaceRequest,
private val spaceInviteResolver: SpaceInviteResolver
private val spaceInviteResolver: SpaceInviteResolver,
private val checkIsUserSpaceMember: CheckIsUserSpaceMember,
private val spaceManager: SpaceManager,
private val saveCurrentSpace: SaveCurrentSpace
) : BaseViewModel() {
val state = MutableStateFlow<ViewState<SpaceInviteView>>(ViewState.Loading)
val state = MutableStateFlow<TypedViewState<SpaceInviteView, ErrorView>>(TypedViewState.Loading)
val commands = MutableSharedFlow<Command>(0)
init {
@ -43,28 +52,48 @@ class RequestJoinSpaceViewModel(
)
).fold(
onSuccess = { view ->
state.value = ViewState.Success(view)
val isAlreadyMember = checkIsUserSpaceMember
.async(view.space)
.getOrDefault(false)
if (isAlreadyMember) {
state.value = TypedViewState.Error(
ErrorView.AlreadySpaceMember(view.space)
)
} else {
state.value = TypedViewState.Success(view)
}
},
onFailure = { e ->
if (e is SpaceInviteError) {
when(e) {
is SpaceInviteError.InvalidInvite -> {
state.value = TypedViewState.Error(
ErrorView.InvalidLink
)
}
is SpaceInviteError.SpaceDeleted -> {
commands.emit(Command.Toast.SpaceDeleted)
commands.emit(Command.Dismiss)
}
is SpaceInviteError.SpaceNotFound -> {
commands.emit(Command.Toast.SpaceNotFound)
commands.emit(Command.Dismiss)
}
}
}
Timber.e(e, "Error while getting space invite view")
}
)
}
} else {
Timber.e("Could not parse invite link: ${params.link}")
state.value = ViewState.Error("Could not parse invite link: ${params.link}")
Timber.w("Could not parse invite link: ${params.link}")
state.value = TypedViewState.Error(ErrorView.InvalidLink)
}
}
fun onRequestToJoinClicked() {
when(val curr = state.value) {
is ViewState.Error -> {
// Do nothing.
}
is ViewState.Loading -> {
// Do nothing.
}
is ViewState.Success -> {
is TypedViewState.Success -> {
viewModelScope.launch {
val fileKey = spaceInviteResolver.parseFileKey(params.link)
val contentId = spaceInviteResolver.parseContentId(params.link)
@ -89,6 +118,22 @@ class RequestJoinSpaceViewModel(
)
}
}
} else -> {
// Do nothing.
}
}
}
fun onOpenSpaceClicked(space: SpaceId) {
viewModelScope.launch {
val curr = spaceManager.get()
if (curr == space.id) {
commands.emit(Command.Dismiss)
} else {
spaceManager.set(space.id)
saveCurrentSpace.async(params = SaveCurrentSpace.Params(space))
// TODO navigate to the target space instead of dismissing
commands.emit(Command.Dismiss)
}
}
}
@ -97,14 +142,20 @@ class RequestJoinSpaceViewModel(
private val params: Params,
private val getSpaceInviteView: GetSpaceInviteView,
private val sendJoinSpaceRequest: SendJoinSpaceRequest,
private val spaceInviteResolver: SpaceInviteResolver
private val spaceInviteResolver: SpaceInviteResolver,
private val checkIsUserSpaceMember: CheckIsUserSpaceMember,
private val saveCurrentSpace: SaveCurrentSpace,
private val spaceManager: SpaceManager
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = RequestJoinSpaceViewModel(
params = params,
getSpaceInviteView = getSpaceInviteView,
sendJoinSpaceRequest = sendJoinSpaceRequest,
spaceInviteResolver = spaceInviteResolver
spaceInviteResolver = spaceInviteResolver,
checkIsUserSpaceMember = checkIsUserSpaceMember,
saveCurrentSpace = saveCurrentSpace,
spaceManager = spaceManager
) as T
}
@ -112,8 +163,15 @@ class RequestJoinSpaceViewModel(
sealed class Command {
sealed class Toast : Command() {
object RequestSent : Toast()
data object RequestSent : Toast()
data object SpaceNotFound : Toast()
data object SpaceDeleted : Toast()
}
object Dismiss: Command()
data object Dismiss: Command()
}
sealed class ErrorView {
data object InvalidLink : ErrorView()
data class AlreadySpaceMember(val space: SpaceId) : ErrorView()
}
}