diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/multiplayer/RequestJoinSpaceDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/multiplayer/RequestJoinSpaceDI.kt index 9ff9aed6e9..4cb6c32c68 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/multiplayer/RequestJoinSpaceDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/multiplayer/RequestJoinSpaceDI.kt @@ -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 diff --git a/app/src/main/java/com/anytypeio/anytype/ui/multiplayer/RequestJoinSpaceFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/multiplayer/RequestJoinSpaceFragment.kt index 9ca85e7c28..e367af80fa 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/multiplayer/RequestJoinSpaceFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/multiplayer/RequestJoinSpaceFragment.kt @@ -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)) + } } } diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/multiplayer/Multiplayer.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/multiplayer/Multiplayer.kt index 4fe94abda6..ca740cdfbd 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/multiplayer/Multiplayer.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/multiplayer/Multiplayer.kt @@ -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() } \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Foundation.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Foundation.kt index 1808ac1b71..55fbf6ae59 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Foundation.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Foundation.kt @@ -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( diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Warnings.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Warnings.kt index fb4142c979..369304f695 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Warnings.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/foundation/Warnings.kt @@ -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, diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/multiplayer/CheckIsUserSpaceMember.kt b/domain/src/main/java/com/anytypeio/anytype/domain/multiplayer/CheckIsUserSpaceMember.kt new file mode 100644 index 0000000000..b13ff1faca --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/multiplayer/CheckIsUserSpaceMember.kt @@ -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(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() + } +} \ No newline at end of file diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 04027037c5..bc72b36720 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1348,7 +1348,11 @@ After approving the request, you can choose the access rights for that person. Delete sharing link Delete link - New members won’t be able to join the space. You can generate a new link anytype. + New members won’t be able to join the space. You can generate a new link anytime. + You are already a member of this space + Open space + Space deleted + Space not found diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt index 1b1b4a1abc..6714f1596e 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt @@ -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 } diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/common/ViewState.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/common/ViewState.kt index 383d4f755c..3a00fe84ec 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/common/ViewState.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/common/ViewState.kt @@ -3,5 +3,11 @@ package com.anytypeio.anytype.presentation.common sealed class ViewState { data class Success(val data: T) : ViewState() data class Error(val error: String) : ViewState() - object Loading : ViewState() + data object Loading : ViewState() +} + +sealed class TypedViewState { + data class Success(val data: T) : TypedViewState() + data class Error(val error: E) : TypedViewState() + data object Loading : TypedViewState() } \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/multiplayer/RequestJoinSpaceViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/multiplayer/RequestJoinSpaceViewModel.kt index f0592bfd06..fd960c228c 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/multiplayer/RequestJoinSpaceViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/multiplayer/RequestJoinSpaceViewModel.kt @@ -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.Loading) + val state = MutableStateFlow>(TypedViewState.Loading) val commands = MutableSharedFlow(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 create(modelClass: Class): 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() + } } \ No newline at end of file