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

DROID-2635 Chats | Enhancement | Foundation for chats (#1420)

Co-authored-by: Konstantin Ivanov <54908981+konstantiniiv@users.noreply.github.com>
This commit is contained in:
Evgenii Kozlov 2024-10-29 21:05:44 +01:00 committed by GitHub
parent b87a454bc7
commit c149de8bce
Signed by: github
GPG key ID: B5690EEEBB952194
69 changed files with 3222 additions and 153 deletions

View file

@ -24,5 +24,5 @@ class DefaultFeatureToggles @Inject constructor(
override val isConciseLogging: Boolean = true
override val enableDiscussionDemo: Boolean = false
override val enableDiscussionDemo: Boolean = true
}

View file

@ -50,6 +50,7 @@ import com.anytypeio.anytype.di.feature.ViewerFilterModule
import com.anytypeio.anytype.di.feature.ViewerSortModule
import com.anytypeio.anytype.di.feature.auth.DaggerDeletedAccountComponent
import com.anytypeio.anytype.di.feature.cover.UnsplashModule
import com.anytypeio.anytype.di.feature.discussions.DaggerDiscussionComponent
import com.anytypeio.anytype.di.feature.gallery.DaggerGalleryInstallationComponent
import com.anytypeio.anytype.di.feature.home.DaggerHomeScreenComponent
import com.anytypeio.anytype.di.feature.library.DaggerLibraryComponent
@ -102,6 +103,7 @@ import com.anytypeio.anytype.di.feature.widgets.SelectWidgetTypeModule
import com.anytypeio.anytype.di.main.MainComponent
import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel
import com.anytypeio.anytype.gallery_experience.viewmodel.GalleryInstallationViewModel
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.editor.EditorViewModel
import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel
import com.anytypeio.anytype.presentation.library.LibraryViewModel
@ -1066,6 +1068,14 @@ class ComponentManager(
.build()
}
val discussionComponent = ComponentMapWithParam { params: BaseViewModel.DefaultParams ->
DaggerDiscussionComponent
.builder()
.withDependencies(findComponentDependencies())
.withParams(params)
.build()
}
val vaultComponent = Component {
DaggerVaultComponent
.factory()

View file

@ -0,0 +1,181 @@
package com.anytypeio.anytype.di.feature.discussions
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_utils.ext.arg
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.ext.daggerViewModel
import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModel
import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModelFactory
import com.anytypeio.anytype.feature_discussions.ui.DiscussionScreenWrapper
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.search.GlobalSearchViewModel
import com.anytypeio.anytype.ui.editor.EditorFragment
import com.anytypeio.anytype.ui.search.GlobalSearchScreen
import com.anytypeio.anytype.ui.settings.typography
import javax.inject.Inject
import timber.log.Timber
class DiscussionFragment : BaseComposeFragment() {
@Inject
lateinit var factory: DiscussionViewModelFactory
private val vm by viewModels<DiscussionViewModel> { factory }
private val ctx get() = arg<Id>(CTX_KEY)
private val space get() = arg<Id>(SPACE_KEY)
// Rendering
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme(typography = typography) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showBottomSheet by remember { mutableStateOf(false) }
DiscussionScreenWrapper(
vm = vm,
onAttachClicked = {
showBottomSheet = true
}
)
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
showBottomSheet = false
},
sheetState = sheetState,
containerColor = colorResource(id = R.color.background_secondary),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
dragHandle = null
) {
val component = componentManager().globalSearchComponent
val searchViewModel = daggerViewModel {
component.get(
params = GlobalSearchViewModel.VmParams(
space = SpaceId(space)
)
).getViewModel()
}
GlobalSearchScreen(
modifier = Modifier.padding(top = 12.dp),
state = searchViewModel.state
.collectAsStateWithLifecycle()
.value
,
onQueryChanged = searchViewModel::onQueryChanged,
onObjectClicked = {
vm.onAttachObject(it)
showBottomSheet = false
},
onShowRelatedClicked = {
// Do nothing.
},
onClearRelatedClicked = {
},
focusOnStart = false
)
}
} else {
componentManager().globalSearchComponent.release()
}
}
LaunchedEffect(Unit) {
vm.navigation.collect { nav ->
when(nav) {
is OpenObjectNavigation.OpenEditor -> {
runCatching {
findNavController().navigate(
R.id.objectNavigation,
EditorFragment.args(
ctx = nav.target,
space = nav.space
)
)
}.onFailure {
Timber.w("Error while opening editor from chat.")
}
}
else -> toast("TODO")
}
}
}
}
}
}
// DI
override fun injectDependencies() {
componentManager()
.discussionComponent
.get(
key = ctx,
param = BaseViewModel.DefaultParams(
ctx = ctx,
space = SpaceId(space)
)
)
.inject(this)
}
override fun releaseDependencies() {
componentManager().discussionComponent.release(ctx)
}
override fun onApplyWindowRootInsets(view: View) {
// Do not apply.
}
companion object {
private const val CTX_KEY = "arg.discussion.ctx"
private const val SPACE_KEY = "arg.discussion.space"
fun args(
space: Id,
ctx: Id
) = bundleOf(
CTX_KEY to ctx,
SPACE_KEY to space
)
}
}

View file

@ -3,14 +3,23 @@ package com.anytypeio.anytype.di.feature.discussions
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.di.feature.EditorSubComponent.Builder
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.chats.ChatEventChannel
import com.anytypeio.anytype.domain.config.UserSettingsRepository
import com.anytypeio.anytype.domain.debugging.Logger
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModelFactory
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.presentation.common.BaseViewModel
import dagger.Binds
import dagger.BindsInstance
import dagger.Component
import dagger.Module
@ -23,10 +32,14 @@ import dagger.Module
)
@PerScreen
interface DiscussionComponent {
@Component.Factory
interface Factory {
fun create(dependencies: DiscussionComponentDependencies): DiscussionComponent
@Component.Builder
interface Builder {
@BindsInstance
fun withParams(params: BaseViewModel.DefaultParams): Builder
fun withDependencies(dependencies: DiscussionComponentDependencies): Builder
fun build(): DiscussionComponent
}
fun inject(fragment: DiscussionFragment)
}
@Module
@ -44,9 +57,15 @@ object DiscussionModule {
interface DiscussionComponentDependencies : ComponentDependencies {
fun blockRepository(): BlockRepository
fun authRepo(): AuthRepository
fun appCoroutineDispatchers(): AppCoroutineDispatchers
fun analytics(): Analytics
fun urlBuilder(): UrlBuilder
fun userPermissionProvider(): UserPermissionProvider
fun eventProxy(): EventProxy
fun featureToggles(): FeatureToggles
fun userSettings(): UserSettingsRepository
fun chatEventChannel(): ChatEventChannel
fun logger(): Logger
fun members(): ActiveSpaceMemberSubscriptionContainer
}

View file

@ -36,6 +36,7 @@ interface GlobalSearchComponent {
}
fun inject(fragment: GlobalSearchFragment)
fun getViewModel(): GlobalSearchViewModel
}
@Module

View file

@ -3,6 +3,7 @@ package com.anytypeio.anytype.di.main
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.data.auth.account.AccountStatusDataChannel
import com.anytypeio.anytype.data.auth.account.AccountStatusRemoteChannel
import com.anytypeio.anytype.data.auth.event.ChatEventRemoteChannel
import com.anytypeio.anytype.data.auth.event.EventDataChannel
import com.anytypeio.anytype.data.auth.event.EventRemoteChannel
import com.anytypeio.anytype.data.auth.event.FileLimitsDataChannel
@ -12,19 +13,21 @@ import com.anytypeio.anytype.data.auth.event.SubscriptionEventRemoteChannel
import com.anytypeio.anytype.data.auth.status.SyncAndP2PStatusEventsStore
import com.anytypeio.anytype.di.main.ConfigModule.DEFAULT_APP_COROUTINE_SCOPE
import com.anytypeio.anytype.domain.account.AccountStatusChannel
import com.anytypeio.anytype.domain.chats.ChatEventChannel
import com.anytypeio.anytype.domain.event.interactor.EventChannel
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
import com.anytypeio.anytype.domain.workspace.FileLimitsEventChannel
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.interactor.AccountStatusMiddlewareChannel
import com.anytypeio.anytype.middleware.interactor.EventHandler
import com.anytypeio.anytype.middleware.interactor.EventHandlerChannel
import com.anytypeio.anytype.middleware.interactor.EventHandlerChannelImpl
import com.anytypeio.anytype.middleware.interactor.EventHandler
import com.anytypeio.anytype.middleware.interactor.FileLimitsMiddlewareChannel
import com.anytypeio.anytype.middleware.interactor.MiddlewareEventChannel
import com.anytypeio.anytype.middleware.interactor.MiddlewareProtobufLogger
import com.anytypeio.anytype.middleware.interactor.MiddlewareSubscriptionEventChannel
import com.anytypeio.anytype.middleware.interactor.SyncAndP2PStatusEventsStoreImpl
import com.anytypeio.anytype.middleware.interactor.events.ChatEventMiddlewareChannel
import com.anytypeio.anytype.presentation.common.PayloadDelegator
import dagger.Binds
import dagger.Module
@ -149,6 +152,35 @@ object EventModule {
@Singleton
fun provideDefaultEventChannel(): EventHandlerChannel = EventHandlerChannelImpl()
//region Chats
@JvmStatic
@Provides
@Singleton
fun provideChatEventChannel(
channel: ChatEventRemoteChannel.Default
): ChatEventChannel = channel
@JvmStatic
@Provides
@Singleton
fun provideChatEventDataChannel(
remote: ChatEventRemoteChannel
): ChatEventRemoteChannel.Default = ChatEventRemoteChannel.Default(
channel = remote
)
@JvmStatic
@Provides
@Singleton
fun provideChatEventMWChannel(
proxy: EventProxy
): ChatEventRemoteChannel = ChatEventMiddlewareChannel(
proxy
)
//endregion
@Module
interface Bindings {

View file

@ -20,6 +20,7 @@ import com.anytypeio.anytype.di.feature.ObjectTypeChangeSubComponent
import com.anytypeio.anytype.di.feature.PersonalizationSettingsSubComponent
import com.anytypeio.anytype.di.feature.SplashDependencies
import com.anytypeio.anytype.di.feature.auth.DeletedAccountDependencies
import com.anytypeio.anytype.di.feature.discussions.DiscussionComponentDependencies
import com.anytypeio.anytype.di.feature.gallery.GalleryInstallationComponentDependencies
import com.anytypeio.anytype.di.feature.home.HomeScreenDependencies
import com.anytypeio.anytype.di.feature.library.LibraryDependencies
@ -128,7 +129,8 @@ interface MainComponent :
GlobalSearchDependencies,
MembershipUpdateComponentDependencies,
VaultComponentDependencies,
AllContentDependencies
AllContentDependencies,
DiscussionComponentDependencies
{
fun inject(app: AndroidApplication)
@ -350,6 +352,11 @@ abstract class ComponentDependenciesModule {
@ComponentDependenciesKey(MembershipUpdateComponentDependencies::class)
abstract fun provideMembershipUpdateComponentDependencies(component: MainComponent): ComponentDependencies
@Binds
@IntoMap
@ComponentDependenciesKey(DiscussionComponentDependencies::class)
abstract fun provideDiscussionComponentDependencies(component: MainComponent): ComponentDependencies
@Binds
@IntoMap
@ComponentDependenciesKey(VaultComponentDependencies::class)

View file

@ -6,6 +6,7 @@ import androidx.navigation.navOptions
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.di.feature.discussions.DiscussionFragment
import com.anytypeio.anytype.presentation.navigation.AppNavigation
import com.anytypeio.anytype.presentation.widgets.collection.Subscription
import com.anytypeio.anytype.ui.allcontent.AllContentFragment
@ -22,7 +23,6 @@ import com.anytypeio.anytype.ui.templates.EditorTemplateFragment.Companion.TYPE_
import com.anytypeio.anytype.ui.templates.EditorTemplateFragment.Companion.TYPE_TEMPLATE_SELECT
import com.anytypeio.anytype.ui.templates.TemplateSelectFragment
import com.anytypeio.anytype.ui.types.create.CreateObjectTypeFragment
import com.anytypeio.anytype.ui.types.create.TypeCreationScreen
import com.anytypeio.anytype.ui.types.edit.TypeEditFragment
import com.anytypeio.anytype.ui.widgets.collection.CollectionFragment
import timber.log.Timber
@ -39,6 +39,16 @@ class Navigator : AppNavigation {
}
}
override fun openChat(target: Id, space: Id) {
navController?.navigate(
R.id.chatScreen,
DiscussionFragment.args(
ctx = target,
space = space
)
)
}
override fun openDocument(target: Id, space: Id) {
navController?.navigate(
R.id.objectNavigation,
@ -49,6 +59,16 @@ class Navigator : AppNavigation {
)
}
override fun openDiscussion(target: Id, space: Id) {
navController?.navigate(
R.id.chatScreen,
DiscussionFragment.args(
ctx = target,
space = space
)
)
}
override fun openModalTemplateSelect(
template: Id,
templateTypeId: Id,

View file

@ -143,6 +143,16 @@ class AllContentFragment : BaseComposeFragment() {
Timber.e(it, "Failed to open document from all content")
}
}
is AllContentViewModel.Command.OpenChat -> {
runCatching {
navigation().openChat(
target = command.target,
space = command.space
)
}.onFailure {
Timber.e(it, "Failed to open a chat from all content")
}
}
is AllContentViewModel.Command.NavigateToSetOrCollection -> {
runCatching {

View file

@ -17,6 +17,7 @@ class NavigationRouter(
target = command.target,
space = command.space
)
is AppNavigation.Command.OpenModalTemplateSelect -> navigation.openModalTemplateSelect(
template = command.template,
templateTypeId = command.templateTypeId,
@ -28,6 +29,10 @@ class NavigationRouter(
space = command.space,
isPopUpToDashboard = command.isPopUpToDashboard
)
is AppNavigation.Command.OpenChat -> navigation.openChat(
target = command.target,
space = command.space
)
is AppNavigation.Command.LaunchObjectSet -> navigation.launchObjectSet(
target = command.target,
space = command.space

View file

@ -55,12 +55,13 @@ class HomeScreenFragment : BaseComposeFragment() {
get() = argOrNull<Boolean>(SHOW_MNEMONIC_KEY) ?: false
set(value) { arguments?.putBoolean(SHOW_MNEMONIC_KEY, value) }
@Inject
lateinit var factory: HomeScreenViewModel.Factory
@Inject
lateinit var featureToggles: FeatureToggles
@Inject
lateinit var factory: HomeScreenViewModel.Factory
private val vm by viewModels<HomeScreenViewModel> { factory }
override fun onCreateView(
@ -117,10 +118,6 @@ class HomeScreenFragment : BaseComposeFragment() {
onCreateDataViewObject = vm::onCreateDataViewObject,
onBackLongClicked = vm::onBackLongClicked
)
if (featureToggles.enableDiscussionDemo) {
DiscussionScreenWrapper()
}
}
}
}
@ -347,6 +344,12 @@ class HomeScreenFragment : BaseComposeFragment() {
view = destination.view
)
}
is Navigation.OpenDiscussion -> runCatching {
navigation().openDiscussion(
target = destination.ctx,
space = destination.space
)
}
is Navigation.ExpandWidget -> runCatching {
navigation().launchCollections(
subscription = destination.subscription,
@ -387,12 +390,6 @@ class HomeScreenFragment : BaseComposeFragment() {
componentManager().homeScreenComponent.release()
}
override fun onApplyWindowRootInsets(view: View) {
if (!featureToggles.enableDiscussionDemo) {
super.onApplyWindowRootInsets(view)
}
}
companion object {
const val SHOW_MNEMONIC_KEY = "arg.home-screen.show-mnemonic"
const val DEEP_LINK_KEY = "arg.home-screen.deep-link"

View file

@ -34,6 +34,7 @@ import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.insets.EDGE_TO_EDGE_MIN_SDK
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.di.feature.discussions.DiscussionFragment
import com.anytypeio.anytype.domain.base.BaseUseCase
import com.anytypeio.anytype.domain.theme.GetTheme
import com.anytypeio.anytype.middleware.discovery.MDNSProvider
@ -222,6 +223,9 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr
Timber.e(it, "Error while editor navigation")
}
}
is OpenObjectNavigation.OpenDiscussion -> {
toast("Cannot open chat from here")
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
toast(getString(R.string.error_unexpected_layout))
}

View file

@ -21,6 +21,7 @@ import com.anytypeio.anytype.core_utils.ext.argString
import com.anytypeio.anytype.core_utils.ext.setupBottomSheetBehavior
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.di.feature.discussions.DiscussionFragment
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.search.GlobalSearchViewModel
import com.anytypeio.anytype.ui.editor.EditorFragment
@ -64,7 +65,7 @@ class GlobalSearchFragment : BaseBottomSheetComposeFragment() {
}
},
onShowRelatedClicked = vm::onShowRelatedClicked,
onClearRelatedClicked = vm::onClearRelatedObjectClicked
onClearRelatedClicked = vm::onClearRelatedObjectClicked,
)
}
LaunchedEffect(Unit) {
@ -79,7 +80,6 @@ class GlobalSearchFragment : BaseBottomSheetComposeFragment() {
)
)
}
is OpenObjectNavigation.OpenDataView -> {
findNavController().navigate(
R.id.dataViewNavigation,
@ -89,7 +89,15 @@ class GlobalSearchFragment : BaseBottomSheetComposeFragment() {
)
)
}
is OpenObjectNavigation.OpenDiscussion -> {
findNavController().navigate(
R.id.chatScreen,
DiscussionFragment.args(
ctx = nav.target,
space = nav.space
)
)
}
else -> {
// Do nothing.
}

View file

@ -101,11 +101,13 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
fun GlobalSearchScreen(
modifier: Modifier = Modifier,
state: GlobalSearchViewModel.ViewState,
onQueryChanged: (String) -> Unit,
onObjectClicked: (GlobalSearchItemView) -> Unit,
onShowRelatedClicked: (GlobalSearchItemView) -> Unit,
onClearRelatedClicked: () -> Unit
onClearRelatedClicked: () -> Unit,
focusOnStart: Boolean = true
) {
val selectionColors = TextSelectionColors(
@ -144,7 +146,7 @@ fun GlobalSearchScreen(
}
Column(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.nestedScroll(rememberNestedScrollInteropConnection())
) {
@ -159,7 +161,6 @@ fun GlobalSearchScreen(
.align(Alignment.CenterHorizontally)
)
Row(
modifier = Modifier
.fillMaxWidth()
@ -364,7 +365,9 @@ fun GlobalSearchScreen(
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
if (focusOnStart) {
focusRequester.requestFocus()
}
}
}
}

View file

@ -89,6 +89,10 @@ class RemoteFilesManageFragment : BaseBottomSheetComposeFragment() {
subscription = command.subscription,
space = command.space
)
is CollectionViewModel.Command.OpenChat -> navigation.openChat(
space = command.space,
target = command.target
)
is CollectionViewModel.Command.ToDesktop -> navigation.exitToDesktop()
is CollectionViewModel.Command.ToSearch -> {
// Do nothing.

View file

@ -21,6 +21,7 @@ import com.anytypeio.anytype.core_utils.ui.BaseFragment
import com.anytypeio.anytype.core_utils.ui.ViewState
import com.anytypeio.anytype.databinding.FragmentSplashBinding
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.di.feature.discussions.DiscussionFragment
import com.anytypeio.anytype.other.DefaultDeepLinkResolver
import com.anytypeio.anytype.presentation.splash.SplashViewModel
import com.anytypeio.anytype.presentation.splash.SplashViewModelFactory

View file

@ -154,6 +154,13 @@ class VaultFragment : BaseComposeFragment() {
}.onFailure {
Timber.e(it, "Error while opening set or collection from vault")
}
is Navigation.OpenChat -> {
findNavController().navigate(R.id.actionOpenSpaceFromVault)
navigation().openChat(
target = destination.ctx,
space = destination.space
)
}
}
}

View file

@ -104,6 +104,10 @@ class CollectionFragment : BaseComposeFragment() {
subscription = command.subscription,
space = space
)
is Command.OpenChat -> navigation.openChat(
target = command.target,
space = command.space
)
is Command.ToDesktop -> navigation.exitToDesktop()
is Command.ToSearch -> navigation.openGlobalSearch(
space = command.space

View file

@ -149,6 +149,11 @@
app:destination="@id/selectSpaceScreen"/>
</navigation>
<fragment
android:id="@+id/chatScreen"
android:name="com.anytypeio.anytype.di.feature.discussions.DiscussionFragment"
android:label="Discussion" />
<fragment
android:id="@+id/homeScreen"
android:name="com.anytypeio.anytype.ui.home.HomeScreenFragment"

View file

@ -1,5 +1,6 @@
package com.anytypeio.anytype.core_models
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.NameServiceNameType
import com.anytypeio.anytype.core_models.primitives.SpaceId
@ -572,4 +573,38 @@ sealed class Command {
val previousVersion: Id
) : VersionHistory()
}
sealed class ChatCommand {
data class AddMessage(
val chat: Id,
val message: Chat.Message
): ChatCommand()
data class DeleteMessage(
val chat: Id,
val msg: Id
): ChatCommand()
data class EditMessage(
val chat: Id,
val message: Chat.Message
): ChatCommand()
data class GetMessages(
val chat: Id,
val beforeMessageId: Id,
val limit: Int
): ChatCommand()
data class SubscribeLastMessages(
val chat: Id,
val limit: Int
): ChatCommand() {
data class Response(
val messages: List<Chat.Message>,
val messageCountBefore: Int
)
}
data class ToggleMessageReaction(
val chat: Id,
val msg: Id,
val emoji: String
): ChatCommand()
}
}

View file

@ -1,6 +1,7 @@
package com.anytypeio.anytype.core_models
import com.anytypeio.anytype.core_models.Block.Content.Text
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.restrictions.DataViewRestrictions
import com.anytypeio.anytype.core_models.restrictions.ObjectRestriction
@ -332,5 +333,31 @@ sealed class Event {
)
}
}
sealed class Chats : Command() {
data class Add(
override val context: Id,
val id: Id,
val order: Id,
val message: Chat.Message
) : Chats()
data class Update(
override val context: Id,
val id: Id,
val message: Chat.Message
) : Chats()
data class Delete(
override val context: Id,
val id: Id
) : Chats()
data class UpdateReactions(
override val context: Id,
val id: Id,
val reactions: Map<String, List<Id>>
) : Chats()
}
}
}

View file

@ -7,6 +7,7 @@ package com.anytypeio.anytype.core_models
object Relations {
const val ID = "id"
const val CHAT_ID = "chatId"
const val COVER = "cover"
const val COVER_TYPE = "coverType"
const val COVER_ID = "coverId"

View file

@ -0,0 +1,84 @@
package com.anytypeio.anytype.core_models.chats
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Id
sealed class Chat {
/**
* @property [id] message id
*/
data class Message(
val id: Id,
val order: Id,
val creator: Id,
val createdAt: Long,
val modifiedAt: Long,
val content: Content?,
val attachments: List<Attachment> = emptyList(),
val reactions: Map<String, List<String>>,
val replyToMessageId: Id? = null,
) {
data class Content(
val text: String,
val style: Block.Content.Text.Style,
val marks: List<Block.Content.Text.Mark>
)
data class Attachment(
val target: Id,
val type: Type
) {
sealed class Type {
data object File: Type()
data object Image: Type()
data object Link: Type()
}
}
companion object {
/**
* New message builder.
*/
fun new(
text: String,
attachments: List<Attachment> = emptyList()
) : Message = Chat.Message(
id = "",
createdAt = 0L,
modifiedAt = 0L,
attachments = attachments,
reactions = emptyMap(),
creator = "",
replyToMessageId = "",
content = Chat.Message.Content(
text = text,
marks = emptyList(),
style = Block.Content.Text.Style.P
),
order = ""
)
/**
* Updated message builder.
*/
fun updated(
id: Id,
text: String
) : Message = Chat.Message(
id = id,
createdAt = 0L,
modifiedAt = 0L,
attachments = emptyList(),
reactions = emptyMap(),
creator = "",
replyToMessageId = "",
content = Chat.Message.Content(
text = text,
marks = emptyList(),
style = Block.Content.Text.Style.P
),
order = ""
)
}
}
}

View file

@ -298,6 +298,13 @@ private fun getP2PCardSettings(
)
)
}
P2PStatus.RESTRICTED -> {
CardSettings(
icon = painterResource(R.drawable.ic_sync_p2p_error),
mainText = stringResource(id = R.string.sync_status_p2p),
secondaryText = stringResource(id = R.string.sync_status_p2p_disabled)
)
}
}
}

View file

@ -121,6 +121,7 @@
<color name="background_notification_primary">#000000</color>
<color name="background_secondary">#FFFFFF</color>
<color name="background_highlighted">#144F4F4F</color>
<color name="background_highlighted_light">#0A4F4F4F</color>
<color name="background_highlighted_medium">#4F4F4F</color>
<color name="background_multiplayer_request">#252525</color>

View file

@ -0,0 +1,17 @@
package com.anytypeio.anytype.data.auth.event
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.domain.chats.ChatEventChannel
import kotlinx.coroutines.flow.Flow
interface ChatEventRemoteChannel {
fun observe(chat: Id): Flow<List<Event.Command.Chats>>
class Default(
private val channel: ChatEventRemoteChannel
) : ChatEventChannel {
override fun observe(chat: Id): Flow<List<Event.Command.Chats>> {
return channel.observe(chat)
}
}
}

View file

@ -9,6 +9,7 @@ import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVSort
import com.anytypeio.anytype.core_models.DVViewer
import com.anytypeio.anytype.core_models.DVViewerType
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.core_models.ManifestInfo
@ -24,6 +25,7 @@ import com.anytypeio.anytype.core_models.SearchResult
import com.anytypeio.anytype.core_models.Struct
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.core_models.WidgetLayout
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
import com.anytypeio.anytype.core_models.history.Version
@ -1041,4 +1043,34 @@ class BlockDataRepository(
override suspend fun diffVersions(command: Command.VersionHistory.DiffVersions): DiffVersionResponse {
return remote.diffVersions(command)
}
override suspend fun addChatMessage(command: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>> {
return remote.addChatMessage(command)
}
override suspend fun editChatMessage(command: Command.ChatCommand.EditMessage) {
remote.editChatMessage(command)
}
override suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage) {
remote.deleteChatMessage(command)
}
override suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): List<Chat.Message> {
return remote.getChatMessages(command)
}
override suspend fun subscribeLastChatMessages(
command: Command.ChatCommand.SubscribeLastMessages
): Command.ChatCommand.SubscribeLastMessages.Response {
return remote.subscribeLastChatMessages(command)
}
override suspend fun toggleChatMessageReaction(command: Command.ChatCommand.ToggleMessageReaction) {
return remote.toggleChatMessageReaction(command = command)
}
override suspend fun unsubscribeChat(chat: Id) {
return remote.unsubscribeChat(chat)
}
}

View file

@ -9,6 +9,7 @@ import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVSort
import com.anytypeio.anytype.core_models.DVViewer
import com.anytypeio.anytype.core_models.DVViewerType
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.core_models.ManifestInfo
@ -24,6 +25,7 @@ import com.anytypeio.anytype.core_models.SearchResult
import com.anytypeio.anytype.core_models.Struct
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.core_models.WidgetLayout
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
import com.anytypeio.anytype.core_models.history.Version
@ -442,4 +444,16 @@ interface BlockRemote {
suspend fun showVersion(command: Command.VersionHistory.ShowVersion): ShowVersionResponse
suspend fun setVersion(command: Command.VersionHistory.SetVersion)
suspend fun diffVersions(command: Command.VersionHistory.DiffVersions): DiffVersionResponse
//region CHATS
suspend fun addChatMessage(command: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>>
suspend fun editChatMessage(command: Command.ChatCommand.EditMessage)
suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage)
suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): List<Chat.Message>
suspend fun subscribeLastChatMessages(command: Command.ChatCommand.SubscribeLastMessages): Command.ChatCommand.SubscribeLastMessages.Response
suspend fun toggleChatMessageReaction(command: Command.ChatCommand.ToggleMessageReaction)
suspend fun unsubscribeChat(chat: Id)
//endregion
}

View file

@ -9,6 +9,7 @@ import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVSort
import com.anytypeio.anytype.core_models.DVViewer
import com.anytypeio.anytype.core_models.DVViewerType
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.core_models.ManifestInfo
@ -24,6 +25,7 @@ import com.anytypeio.anytype.core_models.SearchResult
import com.anytypeio.anytype.core_models.Struct
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.core_models.WidgetLayout
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
import com.anytypeio.anytype.core_models.history.Version
@ -485,4 +487,16 @@ interface BlockRepository {
suspend fun showVersion(command: Command.VersionHistory.ShowVersion): ShowVersionResponse
suspend fun setVersion(command: Command.VersionHistory.SetVersion)
suspend fun diffVersions(command: Command.VersionHistory.DiffVersions): DiffVersionResponse
//region CHATS
suspend fun addChatMessage(command: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>>
suspend fun editChatMessage(command: Command.ChatCommand.EditMessage)
suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage)
suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): List<Chat.Message>
suspend fun subscribeLastChatMessages(command: Command.ChatCommand.SubscribeLastMessages): Command.ChatCommand.SubscribeLastMessages.Response
suspend fun toggleChatMessageReaction(command: Command.ChatCommand.ToggleMessageReaction)
suspend fun unsubscribeChat(chat: Id)
//endregion
}

View file

@ -0,0 +1,19 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
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 AddChatMessage @Inject constructor(
private val repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
) : ResultInteractor<Command.ChatCommand.AddMessage, Pair<Id, List<Event.Command.Chats>>>(dispatchers.io) {
override suspend fun doWork(params: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>> {
return repo.addChatMessage(params)
}
}

View file

@ -0,0 +1,101 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.debugging.Logger
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.scan
class ChatContainer @Inject constructor(
private val repo: BlockRepository,
private val channel: ChatEventChannel,
private val logger: Logger
) {
private val payloads = MutableSharedFlow<List<Event.Command.Chats>>()
fun watch(chat: Id): Flow<List<Chat.Message>> = flow {
val initial = repo.subscribeLastChatMessages(
command = Command.ChatCommand.SubscribeLastMessages(
chat = chat,
limit = DEFAULT_LAST_MESSAGE_COUNT
)
)
emitAll(
merge(
channel.observe(chat = chat),
payloads
).scan(initial.messages) { state, events ->
state.reduce(events)
}
)
}.catch {
logger.logException(it)
emit(emptyList())
}
suspend fun onPayload(events: List<Event.Command.Chats>) {
payloads.emit(events)
}
fun List<Chat.Message>.reduce(events: List<Event.Command.Chats>): List<Chat.Message> {
// Naive implementation
var result = this
events.forEach { event ->
when(event) {
is Event.Command.Chats.Add -> {
if (result.isNotEmpty()) {
val last = result.last()
result = if (last.order < event.order)
result + listOf(event.message)
else {
buildList {
addAll(result)
add(event.message)
}.sortedBy { it.order }
}
} else {
result = listOf(event.message)
}
}
is Event.Command.Chats.Delete -> {
result = result.filter { msg ->
msg.id != event.id
}
}
is Event.Command.Chats.Update -> {
result = result.map { msg ->
if (msg.id == event.id)
event.message
else
msg
}
}
is Event.Command.Chats.UpdateReactions -> {
result = result.map { msg ->
if (msg.id == event.id)
msg.copy(
reactions = event.reactions
)
else
msg
}
}
}
}
return result
}
companion object {
const val DEFAULT_LAST_MESSAGE_COUNT = 0
}
}

View file

@ -0,0 +1,9 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import kotlinx.coroutines.flow.Flow
interface ChatEventChannel {
fun observe(chat: Id): Flow<List<Event.Command.Chats>>
}

View file

@ -0,0 +1,16 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
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 DeleteChatMessage @Inject constructor(
private val repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
) : ResultInteractor<Command.ChatCommand.DeleteMessage, Unit>(dispatchers.io) {
override suspend fun doWork(params: Command.ChatCommand.DeleteMessage) {
return repo.deleteChatMessage(command = params)
}
}

View file

@ -0,0 +1,16 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
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 EditChatMessage @Inject constructor(
private val repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
) : ResultInteractor<Command.ChatCommand.EditMessage, Unit>(dispatchers.io) {
override suspend fun doWork(params: Command.ChatCommand.EditMessage) {
return repo.editChatMessage(command = params)
}
}

View file

@ -0,0 +1,19 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.chats.Chat
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 GetChatMessages @Inject constructor(
private val repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): ResultInteractor<Command.ChatCommand.GetMessages, List<Chat.Message>>(dispatchers.io) {
override suspend fun doWork(
params: Command.ChatCommand.GetMessages
): List<Chat.Message> {
return repo.getChatMessages(params)
}
}

View file

@ -0,0 +1,16 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
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 ToggleChatMessageReaction @Inject constructor(
private val repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
) : ResultInteractor<Command.ChatCommand.ToggleMessageReaction, Unit>(dispatchers.io) {
override suspend fun doWork(params: Command.ChatCommand.ToggleMessageReaction) {
return repo.toggleChatMessageReaction(command = params)
}
}

View file

@ -0,0 +1,359 @@
package com.anytypeio.anytype.domain.chats
import app.cash.turbine.test
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.TextStyle
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.common.DefaultCoroutineTestRule
import com.anytypeio.anytype.domain.debugging.Logger
import com.anytypeio.anytype.test_utils.MockDataFactory
import kotlin.test.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
class ChatContainerTest {
@get:Rule
val rule = DefaultCoroutineTestRule()
val dispatchers = AppCoroutineDispatchers(
io = rule.dispatcher,
computation = rule.dispatcher,
main = rule.dispatcher
)
@Mock
lateinit var channel: ChatEventChannel
@Mock
lateinit var repo: BlockRepository
@Mock
lateinit var logger: Logger
private val givenChatID = MockDataFactory.randomUuid()
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test()
fun `should add one message to basic initial state`() = runTest {
val container = ChatContainer(
repo = repo,
channel = channel,
logger = logger
)
val msg = StubChatMessage(
content = StubChatMessageContent(
text = "With seemingly endless talent and versatility, Sully puts his garage hat on to produce one super-slick plate"
)
)
repo.stub {
onBlocking {
subscribeLastChatMessages(
Command.ChatCommand.SubscribeLastMessages(
chat = givenChatID,
limit = ChatContainer.DEFAULT_LAST_MESSAGE_COUNT
)
)
} doReturn Command.ChatCommand.SubscribeLastMessages.Response(
messages = emptyList(),
messageCountBefore = 0
)
}
channel.stub {
on {
observe(chat = givenChatID)
} doReturn flow {
delay(300)
emit(
listOf(
Event.Command.Chats.Add(
context = givenChatID,
message = msg,
id = msg.id,
order = "A"
)
)
)
}
}
container.watch(givenChatID).test {
val first = awaitItem()
assertEquals(
expected = emptyList(),
actual = first
)
advanceUntilIdle()
val second = awaitItem()
assertEquals(
expected = listOf(
msg
),
actual = second
)
}
}
@Test()
fun `should update existing message`() = runTest {
val container = ChatContainer(
repo = repo,
channel = channel,
logger = logger
)
val initialMsg = StubChatMessage(
content = StubChatMessageContent(
text = "Hello, Walter"
)
)
repo.stub {
onBlocking {
subscribeLastChatMessages(
Command.ChatCommand.SubscribeLastMessages(
chat = givenChatID,
limit = ChatContainer.DEFAULT_LAST_MESSAGE_COUNT
)
)
} doReturn Command.ChatCommand.SubscribeLastMessages.Response(
messages = listOf(initialMsg),
messageCountBefore = 0
)
}
channel.stub {
on {
observe(chat = givenChatID)
} doReturn flow {
delay(300)
emit(
listOf(
Event.Command.Chats.Delete(
context = givenChatID,
id = initialMsg.id,
)
)
)
}
}
container.watch(givenChatID).test {
val first = awaitItem()
assertEquals(
expected = listOf(
initialMsg
),
actual = first
)
advanceUntilIdle()
val second = awaitItem()
assertEquals(
expected = emptyList(),
actual = second
)
}
}
@Test()
fun `should delete existing message`() = runTest {
val container = ChatContainer(
repo = repo,
channel = channel,
logger = logger
)
val initialMsg = StubChatMessage(
content = StubChatMessageContent(
text = "Hello, "
)
)
val msgAfterUpdate = initialMsg.copy(
content = initialMsg.content?.copy(
text = "Hello, Walter"
)
)
repo.stub {
onBlocking {
subscribeLastChatMessages(
Command.ChatCommand.SubscribeLastMessages(
chat = givenChatID,
limit = ChatContainer.DEFAULT_LAST_MESSAGE_COUNT
)
)
} doReturn Command.ChatCommand.SubscribeLastMessages.Response(
messages = listOf(initialMsg),
messageCountBefore = 0
)
}
channel.stub {
on {
observe(chat = givenChatID)
} doReturn flow {
delay(300)
emit(
listOf(
Event.Command.Chats.Update(
context = givenChatID,
message = msgAfterUpdate,
id = initialMsg.id,
)
)
)
}
}
container.watch(givenChatID).test {
val first = awaitItem()
assertEquals(
expected = listOf(
initialMsg
),
actual = first
)
advanceUntilIdle()
val second = awaitItem()
assertEquals(
expected = listOf(
msgAfterUpdate
),
actual = second
)
}
}
@Test()
fun `should insert new message before existing message according to alphabetic sorting`() = runTest {
val container = ChatContainer(
repo = repo,
channel = channel,
logger = logger
)
val initialMsg = StubChatMessage(
order = "B",
content = StubChatMessageContent(
text = "Hello, "
)
)
val newMsg = StubChatMessage(
content = StubChatMessageContent(
text = "Hello, "
),
order = "A"
)
repo.stub {
onBlocking {
subscribeLastChatMessages(
Command.ChatCommand.SubscribeLastMessages(
chat = givenChatID,
limit = ChatContainer.DEFAULT_LAST_MESSAGE_COUNT
)
)
} doReturn Command.ChatCommand.SubscribeLastMessages.Response(
messages = listOf(initialMsg),
messageCountBefore = 0
)
}
channel.stub {
on {
observe(chat = givenChatID)
} doReturn flow {
delay(300)
emit(
listOf(
Event.Command.Chats.Add(
context = givenChatID,
message = newMsg,
id = newMsg.id,
order = newMsg.order
)
)
)
}
}
container.watch(givenChatID).test {
val first = awaitItem()
assertEquals(
expected = listOf(
initialMsg
),
actual = first
)
advanceUntilIdle()
val second = awaitItem()
assertEquals(
expected = listOf(
newMsg,
initialMsg
),
actual = second
)
}
}
// TODO move to test-utils
fun StubChatMessage(
id: Id = MockDataFactory.randomUuid(),
order: Id = MockDataFactory.randomUuid(),
creator: Id = MockDataFactory.randomUuid(),
timestamp: Long = MockDataFactory.randomLong(),
modifiedAt: Long = MockDataFactory.randomLong(),
reactions: Map<String, List<Id>> = emptyMap(),
content: Chat.Message.Content? = null
): Chat.Message = Chat.Message(
id = id,
order = order,
creator = creator,
createdAt = timestamp,
reactions = reactions,
content = content,
modifiedAt = modifiedAt
)
// TODO move to test-utils
fun StubChatMessageContent(
text: String,
style: TextStyle = TextStyle.P,
marks: List<Block.Content.Text.Mark> = emptyList()
): Chat.Message.Content = Chat.Message.Content(
text = text,
style = style,
marks = marks
)
}

View file

@ -705,6 +705,14 @@ class AllContentViewModel(
Timber.e("Unexpected layout: ${navigation.layout}")
commands.emit(Command.SendToast.UnexpectedLayout(navigation.layout?.name.orEmpty()))
}
is OpenObjectNavigation.OpenDiscussion -> {
commands.emit(
Command.OpenChat(
target = navigation.target,
space = navigation.space
)
)
}
OpenObjectNavigation.NonValidObject -> {
Timber.e("Object id is missing")
}
@ -972,6 +980,7 @@ class AllContentViewModel(
//endregion
sealed class Command {
data class OpenChat(val target: Id, val space: Id) : Command()
data class NavigateToEditor(val id: Id, val space: Id) : Command()
data class NavigateToSetOrCollection(val id: Id, val space: Id) : Command()
data class NavigateToBin(val space: Id) : Command()

View file

@ -37,6 +37,7 @@ dependencies {
implementation libs.composeFoundation
implementation libs.composeToolingPreview
implementation libs.composeMaterial3
implementation libs.composeMaterial
implementation libs.coilCompose

View file

@ -1,10 +1,28 @@
package com.anytypeio.anytype.feature_discussions.presentation
import com.anytypeio.anytype.core_models.Hash
import com.anytypeio.anytype.core_models.chats.Chat
sealed interface DiscussionView {
data class Message(
val id: String,
val msg: String,
val content: String,
val author: String,
val timestamp: Long
) : DiscussionView
val timestamp: Long,
val attachments: List<Chat.Message.Attachment> = emptyList(),
val reactions: List<Reaction> = emptyList(),
val isUserAuthor: Boolean = false,
val isEdited: Boolean = false,
val avatar: Avatar = Avatar.Initials()
) : DiscussionView {
data class Reaction(
val emoji: String,
val count: Int,
val isSelected: Boolean = false
)
sealed class Avatar {
data class Initials(val initial: String = ""): Avatar()
data class Image(val hash: Hash): Avatar()
}
}
}

View file

@ -1,7 +1,280 @@
package com.anytypeio.anytype.feature_discussions.presentation
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.base.onFailure
import com.anytypeio.anytype.domain.base.onSuccess
import com.anytypeio.anytype.domain.chats.AddChatMessage
import com.anytypeio.anytype.domain.chats.ChatContainer
import com.anytypeio.anytype.domain.chats.DeleteChatMessage
import com.anytypeio.anytype.domain.chats.EditChatMessage
import com.anytypeio.anytype.domain.chats.ToggleChatMessageReaction
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer.Store
import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.search.GlobalSearchItemView
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
class DiscussionViewModel : BaseViewModel() {
class DiscussionViewModel(
private val params: DefaultParams,
private val setObjectDetails: SetObjectDetails,
private val openObject: OpenObject,
private val chatContainer: ChatContainer,
private val addChatMessage: AddChatMessage,
private val editChatMessage: EditChatMessage,
private val deleteChatMessage: DeleteChatMessage,
private val toggleChatMessageReaction: ToggleChatMessageReaction,
private val members: ActiveSpaceMemberSubscriptionContainer,
private val getAccount: GetAccount,
private val urlBuilder: UrlBuilder
) : BaseViewModel() {
val name = MutableStateFlow<String?>(null)
val messages = MutableStateFlow<List<DiscussionView.Message>>(emptyList())
val attachments = MutableStateFlow<List<GlobalSearchItemView>>(emptyList())
val commands = MutableSharedFlow<UXCommand>()
val navigation = MutableSharedFlow<OpenObjectNavigation>()
val chatBoxMode = MutableStateFlow<ChatBoxMode>(ChatBoxMode.Default)
// TODO naive implementation; switch to state
private lateinit var chat: Id
init {
viewModelScope.launch {
val account = requireNotNull(getAccount.async(Unit).getOrNull())
openObject.async(
OpenObject.Params(
spaceId = params.space,
obj = params.ctx,
saveAsLastOpened = false
)
).fold(
onSuccess = { obj ->
val root = ObjectWrapper.Basic(obj.details[params.ctx].orEmpty())
name.value = root.name
proceedWithObservingChatMessages(
account = account.id,
root = root
)
},
onFailure = {
Timber.e(it, "Error while opening chat object")
}
)
}
}
private suspend fun proceedWithObservingChatMessages(
account: Id,
root: ObjectWrapper.Basic
) {
val chat = root.getValue<Id>(Relations.CHAT_ID)
if (chat != null) {
this.chat = chat
chatContainer
.watch(chat)
.onEach { Timber.d("Got new update: $it") }
.collect {
messages.value = it.map { msg ->
val member = members.get().let { type ->
when(type) {
is Store.Data -> type.members.find { member ->
member.identity == msg.creator
}
is Store.Empty -> null
}
}
DiscussionView.Message(
id = msg.id,
timestamp = msg.createdAt * 1000,
content = msg.content?.text.orEmpty(),
author = member?.name ?: msg.creator.takeLast(5),
isUserAuthor = msg.creator == account,
isEdited = msg.modifiedAt > msg.createdAt,
reactions = msg.reactions.map{ (emoji, ids) ->
DiscussionView.Message.Reaction(
emoji = emoji,
count = ids.size,
isSelected = ids.contains(account)
)
},
attachments = msg.attachments,
avatar = if (member != null && !member.iconImage.isNullOrEmpty()) {
DiscussionView.Message.Avatar.Image(
urlBuilder.thumbnail(member.iconImage!!)
)
} else {
DiscussionView.Message.Avatar.Initials(member?.name.orEmpty())
}
)
}.reversed()
}
} else {
Timber.w("Chat ID was missing in chat smart-object details")
}
}
fun onMessageSent(msg: String) {
Timber.d("DROID-2635 OnMessageSent: $msg")
viewModelScope.launch {
when(val mode = chatBoxMode.value) {
is ChatBoxMode.Default -> {
// TODO consider moving this use-case inside chat container
addChatMessage.async(
params = Command.ChatCommand.AddMessage(
chat = chat,
message = Chat.Message.new(
text = msg,
attachments = attachments.value.map { a ->
Chat.Message.Attachment(
target = a.id,
type = Chat.Message.Attachment.Type.Link
)
}
)
)
).onSuccess { (id, payload) ->
attachments.value = emptyList()
chatContainer.onPayload(payload)
delay(JUMP_TO_BOTTOM_DELAY)
commands.emit(UXCommand.JumpToBottom)
}.onFailure {
Timber.e(it, "Error while adding message")
}
}
is ChatBoxMode.EditMessage -> {
editChatMessage.async(
params = Command.ChatCommand.EditMessage(
chat = chat,
message = Chat.Message.updated(
id = mode.msg,
text = msg
)
)
).onSuccess {
delay(JUMP_TO_BOTTOM_DELAY)
commands.emit(UXCommand.JumpToBottom)
}.onFailure {
Timber.e(it, "Error while adding message")
}.onSuccess {
chatBoxMode.value = ChatBoxMode.Default
}
}
}
}
}
fun onRequestEditMessageClicked(msg: DiscussionView.Message) {
Timber.d("onRequestEditMessageClicked")
viewModelScope.launch {
chatBoxMode.value = ChatBoxMode.EditMessage(msg.id)
}
}
fun onTitleChanged(input: String) {
Timber.d("DROID-2635 OnTitleChanged: $input")
viewModelScope.launch {
name.value = input
setObjectDetails.async(
params = SetObjectDetails.Params(
ctx = params.ctx,
details = mapOf(
Relations.NAME to input
)
)
)
}
}
fun onAttachObject(obj: GlobalSearchItemView) {
attachments.value = listOf(obj)
}
fun onClearAttachmentClicked() {
attachments.value = emptyList()
}
fun onReacted(msg: Id, reaction: String) {
Timber.d("onReacted")
viewModelScope.launch {
val message = messages.value.find { it.id == msg }
if (message != null) {
toggleChatMessageReaction.async(
Command.ChatCommand.ToggleMessageReaction(
chat = chat,
msg = msg,
emoji = reaction
)
).onFailure {
Timber.e(it, "Error while toggling chat message reaction")
}
} else {
Timber.w("Target message not found for reaction")
}
}
}
fun onDeleteMessage(msg: DiscussionView.Message) {
Timber.d("onDeleteMessageClicked")
viewModelScope.launch {
deleteChatMessage.async(
Command.ChatCommand.DeleteMessage(
chat = chat,
msg = msg.id
)
).onFailure {
Timber.e(it, "Error while deleting chat message")
}
}
}
fun onAttachmentClicked(attachment: Chat.Message.Attachment) {
viewModelScope.launch {
// TODO naive implementation. Currently used for debugging.
navigation.emit(
OpenObjectNavigation.OpenEditor(
target = attachment.target,
space = params.space.id
)
)
}
}
fun onExitEditMessageMode() {
viewModelScope.launch {
chatBoxMode.value = ChatBoxMode.Default
}
}
sealed class UXCommand {
data object JumpToBottom: UXCommand()
data class SetChatBoxInput(val input: String): UXCommand()
}
sealed class ChatBoxMode {
data object Default : ChatBoxMode()
data class EditMessage(val msg: Id) : ChatBoxMode()
}
companion object {
/**
* Delay before jump-to-bottom after adding new message to the chat.
*/
const val JUMP_TO_BOTTOM_DELAY = 50L
}
}

View file

@ -2,10 +2,44 @@ package com.anytypeio.anytype.feature_discussions.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.chats.AddChatMessage
import com.anytypeio.anytype.domain.chats.ChatContainer
import com.anytypeio.anytype.domain.chats.DeleteChatMessage
import com.anytypeio.anytype.domain.chats.EditChatMessage
import com.anytypeio.anytype.domain.chats.ToggleChatMessageReaction
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.presentation.common.BaseViewModel
import javax.inject.Inject
class DiscussionViewModelFactory @Inject constructor(
private val params: BaseViewModel.DefaultParams,
private val setObjectDetails: SetObjectDetails,
private val openObject: OpenObject,
private val chatContainer: ChatContainer,
private val addChatMessage: AddChatMessage,
private val editChatMessage: EditChatMessage,
private val deleteChatMessage: DeleteChatMessage,
private val toggleChatMessageReaction: ToggleChatMessageReaction,
private val members: ActiveSpaceMemberSubscriptionContainer,
private val getAccount: GetAccount,
private val urlBuilder: UrlBuilder
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = DiscussionViewModel() as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = DiscussionViewModel(
params = params,
setObjectDetails = setObjectDetails,
openObject = openObject,
chatContainer = chatContainer,
addChatMessage = addChatMessage,
toggleChatMessageReaction = toggleChatMessageReaction,
members = members,
getAccount = getAccount,
deleteChatMessage = deleteChatMessage,
urlBuilder = urlBuilder,
editChatMessage = editChatMessage
) as T
}

View file

@ -1,9 +1,11 @@
package com.anytypeio.anytype.feature_discussions.ui
import android.content.res.Configuration
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.feature_discussions.R
import com.anytypeio.anytype.feature_discussions.presentation.DiscussionView
import kotlin.time.DurationUnit
@ -17,23 +19,32 @@ fun DiscussionPreview() {
messages = listOf(
DiscussionView.Message(
id = "1",
msg = stringResource(id = R.string.default_text_placeholder),
content = stringResource(id = R.string.default_text_placeholder),
author = "Walter",
timestamp = System.currentTimeMillis()
),
DiscussionView.Message(
id = "2",
msg = stringResource(id = R.string.default_text_placeholder),
content = stringResource(id = R.string.default_text_placeholder),
author = "Leo",
timestamp = System.currentTimeMillis()
),
DiscussionView.Message(
id = "3",
msg = stringResource(id = R.string.default_text_placeholder),
content = stringResource(id = R.string.default_text_placeholder),
author = "Gilbert",
timestamp = System.currentTimeMillis()
)
)
),
scrollState = LazyListState(),
title = "Conversations with friends",
onTitleChanged = {},
onTitleFocusChanged = {},
onReacted = { a, b -> },
onDeleteMessage = {},
onCopyMessage = {},
onAttachmentClicked = {},
onEditMessage = {}
)
}
@ -48,7 +59,7 @@ fun DiscussionScreenPreview() {
add(
DiscussionView.Message(
id = idx.toString(),
msg = stringResource(id = R.string.default_text_placeholder),
content = stringResource(id = R.string.default_text_placeholder),
author = "User ${idx.inc()}",
timestamp =
System.currentTimeMillis()
@ -57,7 +68,19 @@ fun DiscussionScreenPreview() {
)
)
}
}
}.reversed(),
onMessageSent = {},
onTitleChanged = {},
onAttachClicked = {},
attachments = emptyList(),
onClearAttachmentClicked = {},
lazyListState = LazyListState(),
onReacted = { a, b -> },
onCopyMessage = {},
onDeleteMessage = {},
onAttachmentClicked = {},
onEditMessage = {},
onExitEditMessageMode = {}
)
}
@ -68,6 +91,52 @@ fun BubblePreview() {
Bubble(
name = "Leo Marx",
msg = stringResource(id = R.string.default_text_placeholder),
timestamp = System.currentTimeMillis()
timestamp = System.currentTimeMillis(),
onReacted = {},
onDeleteMessage = {},
onCopyMessage = {},
onAttachmentClicked = {},
onEditMessage = {}
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode")
@Composable
fun BubbleEditedPreview() {
Bubble(
name = "Leo Marx",
msg = stringResource(id = R.string.default_text_placeholder),
isEdited = true,
timestamp = System.currentTimeMillis(),
onReacted = {},
onDeleteMessage = {},
onCopyMessage = {},
onAttachmentClicked = {},
onEditMessage = {}
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode")
@Composable
fun BubbleWithAttachmentPreview() {
Bubble(
name = "Leo Marx",
msg = stringResource(id = R.string.default_text_placeholder),
timestamp = System.currentTimeMillis(),
onReacted = {},
onDeleteMessage = {},
onCopyMessage = {},
attachments = buildList {
add(
Chat.Message.Attachment(
target = "Walter Benjamin",
type = Chat.Message.Attachment.Type.Image
)
)
},
onAttachmentClicked = {},
onEditMessage = {}
)
}

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7,7L12,12L7,17"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/glyph_selected"
android:strokeLineCap="round"/>
<path
android:pathData="M17,7L12,12L17,17"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/glyph_selected"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:pathData="M4,7L9,12L14,7"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/glyph_selected"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:pathData="M9.5,14C9.5,14.828 8.828,15.5 8,15.5C7.172,15.5 6.5,14.828 6.5,14C6.5,13.172 7.172,12.5 8,12.5C8.828,12.5 9.5,13.172 9.5,14ZM15.5,14C15.5,14.828 14.828,15.5 14,15.5C13.172,15.5 12.5,14.828 12.5,14C12.5,13.172 13.172,12.5 14,12.5C14.828,12.5 15.5,13.172 15.5,14ZM20,15.5C20.828,15.5 21.5,14.828 21.5,14C21.5,13.172 20.828,12.5 20,12.5C19.172,12.5 18.5,13.172 18.5,14C18.5,14.828 19.172,15.5 20,15.5Z"
android:fillColor="@color/glyph_active"
android:fillType="evenOdd"/>
</vector>

View file

@ -1814,4 +1814,10 @@ Please provide specific details of your needs here.</string>
<string name="main_navigation_content_desc_search_button">Search objects button</string>
<string name="main_navigation_content_desc_create_button">Create object button</string>
<string name="chat">Chat</string>
<string name="copy">Copy</string>
<string name="chats_edit_message">Edit message</string>
<string name="chats_message_edited">edited</string>
<string name="chat_empty_state_message">There is no messages yet.\nBe the first to start a discussion.</string>
</resources>

View file

@ -10,6 +10,7 @@ import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVSort
import com.anytypeio.anytype.core_models.DVViewer
import com.anytypeio.anytype.core_models.DVViewerType
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.core_models.ManifestInfo
@ -25,6 +26,7 @@ import com.anytypeio.anytype.core_models.SearchResult
import com.anytypeio.anytype.core_models.Struct
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.core_models.WidgetLayout
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
import com.anytypeio.anytype.core_models.history.Version
@ -1004,4 +1006,38 @@ class BlockMiddleware(
override suspend fun diffVersions(command: Command.VersionHistory.DiffVersions): DiffVersionResponse {
return middleware.diffVersions(command)
}
override suspend fun addChatMessage(command: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>> {
return middleware.chatAddMessage(command)
}
override suspend fun editChatMessage(command: Command.ChatCommand.EditMessage) {
middleware.chatEditMessageContent(command)
}
override suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage) {
middleware.chatDeleteMessage(command)
}
override suspend fun getChatMessages(
command: Command.ChatCommand.GetMessages
): List<Chat.Message> {
return middleware.chatGetMessages(command)
}
override suspend fun subscribeLastChatMessages(
command: Command.ChatCommand.SubscribeLastMessages
): Command.ChatCommand.SubscribeLastMessages.Response {
return middleware.chatSubscribeLastMessages(command)
}
override suspend fun toggleChatMessageReaction(command: Command.ChatCommand.ToggleMessageReaction) {
middleware.chatToggleMessageReaction(
command = command
)
}
override suspend fun unsubscribeChat(chat: Id) {
return middleware.chatUnsubscribe(chat = chat)
}
}

View file

@ -15,6 +15,7 @@ import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVSort
import com.anytypeio.anytype.core_models.DVViewer
import com.anytypeio.anytype.core_models.DVViewerType
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.core_models.ManifestInfo
@ -31,6 +32,7 @@ import com.anytypeio.anytype.core_models.SearchResult
import com.anytypeio.anytype.core_models.Struct
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.core_models.WidgetLayout
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
import com.anytypeio.anytype.core_models.history.Version
@ -46,6 +48,7 @@ import com.anytypeio.anytype.core_utils.tools.ThreadInfo
import com.anytypeio.anytype.middleware.BuildConfig
import com.anytypeio.anytype.middleware.auth.toAccountSetup
import com.anytypeio.anytype.middleware.const.Constants
import com.anytypeio.anytype.middleware.interactor.events.payload
import com.anytypeio.anytype.middleware.mappers.MDVFilter
import com.anytypeio.anytype.middleware.mappers.MDetail
import com.anytypeio.anytype.middleware.mappers.MNetworkMode
@ -2695,6 +2698,98 @@ class Middleware @Inject constructor(
return response.toCoreModel(context = command.objectId)
}
@Throws
fun chatAddMessage(command: Command.ChatCommand.AddMessage) : Pair<Id, List<Event.Command.Chats>> {
val request = Rpc.Chat.AddMessage.Request(
chatObjectId = command.chat,
message = command.message.mw()
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatAddMessage(request) }
logResponseIfDebug(response, time)
val events = response
.event
?.messages
?.mapNotNull { msg ->
msg.payload(contextId = command.chat)
}
.orEmpty()
return response.messageId to events
}
@Throws
fun chatEditMessageContent(command: Command.ChatCommand.EditMessage) {
val request = Rpc.Chat.EditMessageContent.Request(
chatObjectId = command.chat,
messageId = command.message.id,
editedMessage = command.message.mw()
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatEditMessage(request) }
logResponseIfDebug(response, time)
}
@Throws
fun chatGetMessages(command: Command.ChatCommand.GetMessages) : List<Chat.Message> {
val request = Rpc.Chat.GetMessages.Request(
chatObjectId = command.chat
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatGetMessages(request) }
logResponseIfDebug(response, time)
return response.messages.map { it.core() }
}
@Throws
fun chatDeleteMessage(command: Command.ChatCommand.DeleteMessage) {
val request = Rpc.Chat.DeleteMessage.Request(
chatObjectId = command.chat,
messageId = command.msg
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatDeleteMessage(request) }
logResponseIfDebug(response, time)
}
@Throws
fun chatSubscribeLastMessages(
command: Command.ChatCommand.SubscribeLastMessages
): Command.ChatCommand.SubscribeLastMessages.Response {
val request = Rpc.Chat.SubscribeLastMessages.Request(
chatObjectId = command.chat,
limit = command.limit
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatSubscribeLastMessages(request) }
logResponseIfDebug(response, time)
return Command.ChatCommand.SubscribeLastMessages.Response(
messages = response.messages.map { it.core() },
messageCountBefore = response.numMessagesBefore
)
}
@Throws
fun chatToggleMessageReaction(
command: Command.ChatCommand.ToggleMessageReaction
) {
val request = Rpc.Chat.ToggleMessageReaction.Request(
chatObjectId = command.chat,
messageId = command.msg,
emoji = command.emoji
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatToggleMessageReaction(request) }
logResponseIfDebug(response, time)
}
@Throws
fun chatUnsubscribe(chat: Id) {
val request = Rpc.Chat.Unsubscribe.Request(chatObjectId = chat)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.chatUnsubscribe(request) }
logResponseIfDebug(response, time)
}
private fun logRequestIfDebug(request: Any) {
if (BuildConfig.DEBUG) {
logger.logRequest(request).also {

View file

@ -4,6 +4,7 @@ import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.middleware.BuildConfig
import com.anytypeio.anytype.middleware.mappers.MWidgetLayout
import com.anytypeio.anytype.middleware.mappers.core
import com.anytypeio.anytype.middleware.mappers.toCoreModel
import com.anytypeio.anytype.middleware.mappers.toCoreModels
import com.anytypeio.anytype.middleware.mappers.toCoreModelsAlign
@ -291,6 +292,44 @@ fun anytype.Event.Message.toCoreModels(
isCollection = event.value_
)
}
chatAdd != null -> {
val event = chatAdd
checkNotNull(event)
Event.Command.Chats.Add(
context = context,
id = event.id,
order = event.orderId,
message = requireNotNull(event.message).core()
)
}
chatDelete != null -> {
val event = chatDelete
checkNotNull(event)
Event.Command.Chats.Delete(
context = context,
id = event.id
)
}
chatUpdate != null -> {
val event = chatUpdate
checkNotNull(event)
Event.Command.Chats.Update(
context = context,
id = event.id,
message = requireNotNull(event.message).core()
)
}
chatUpdateReactions != null -> {
val event = chatUpdateReactions
checkNotNull(event)
Event.Command.Chats.UpdateReactions(
context = context,
id = event.id,
reactions = event.reactions?.reactions.orEmpty().mapValues { (unicode, identities) ->
identities.ids
}
)
}
else -> {
if (BuildConfig.DEBUG) {
Timber.w("Skipped event while mapping: $this")

View file

@ -0,0 +1,77 @@
package com.anytypeio.anytype.middleware.interactor.events
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.data.auth.event.ChatEventRemoteChannel
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.mappers.MEventMessage
import com.anytypeio.anytype.middleware.mappers.core
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapNotNull
class ChatEventMiddlewareChannel(
private val eventProxy: EventProxy
): ChatEventRemoteChannel {
override fun observe(chat: Id): Flow<List<Event.Command.Chats>> {
return eventProxy
.flow()
.filter { it.contextId == chat }
.mapNotNull { item ->
item.messages.mapNotNull { msg -> msg.payload(contextId = item.contextId) }
}.filter { events ->
events.isNotEmpty()
}
}
}
fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? {
return when {
chatAdd != null -> {
val event = chatAdd
checkNotNull(event)
Event.Command.Chats.Add(
context = contextId,
order = event.orderId,
id = event.id,
message = requireNotNull(event.message?.core())
)
}
chatUpdate != null -> {
val event = chatUpdate
checkNotNull(event)
Event.Command.Chats.Update(
context = contextId,
id = event.id,
message = requireNotNull(event.message?.core())
)
}
chatDelete != null -> {
val event = chatDelete
checkNotNull(event)
Event.Command.Chats.Delete(
context = contextId,
id = event.id
)
}
chatUpdateReactions != null -> {
val event = chatUpdateReactions
checkNotNull(event)
Event.Command.Chats.UpdateReactions(
context = contextId,
id = event.id,
reactions = event.reactions?.reactions?.mapValues { (unicode, identities) ->
identities.ids
} ?: emptyMap()
)
}
else -> {
null
}
}
}

View file

@ -3,6 +3,9 @@ package com.anytypeio.anytype.middleware.mappers
import anytype.Event.P2PStatus
import anytype.Event.Space
typealias MEvent = anytype.Event
typealias MEventMessage = anytype.Event.Message
typealias MAccount = anytype.model.Account
typealias MAccountStatus = anytype.model.Account.Status
typealias MAccountStatusType = anytype.model.Account.StatusType
@ -35,6 +38,13 @@ typealias MBPosition = anytype.model.Block.Position
typealias MBSplitMode = anytype.Rpc.Block.Split.Request.Mode
typealias MBTableOfContents = anytype.model.Block.Content.TableOfContents
typealias MChatMessage = anytype.model.ChatMessage
typealias MChatMessageContent = anytype.model.ChatMessage.MessageContent
typealias MChatMessageAttachment = anytype.model.ChatMessage.Attachment
typealias MChatMessageAttachmentType = anytype.model.ChatMessage.Attachment.AttachmentType
typealias MChatMessageReactions = anytype.model.ChatMessage.Reactions
typealias MChatMessageReactionIdentity = anytype.model.ChatMessage.Reactions.IdentityList
typealias MDV = anytype.model.Block.Content.Dataview
typealias MDVView = anytype.model.Block.Content.Dataview.View
typealias MDVViewType = anytype.model.Block.Content.Dataview.View.Type

View file

@ -44,6 +44,7 @@ import com.anytypeio.anytype.core_models.Relation
import com.anytypeio.anytype.core_models.RelationFormat
import com.anytypeio.anytype.core_models.RelationLink
import com.anytypeio.anytype.core_models.SpaceUsage
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
import com.anytypeio.anytype.core_models.history.Version
@ -1108,6 +1109,35 @@ fun MP2PStatus.toCoreModel(): P2PStatus = when (this) {
MP2PStatus.Restricted -> P2PStatus.RESTRICTED
}
fun MChatMessage.core(): Chat.Message = Chat.Message(
id = id,
content = message?.core(),
creator = creator,
createdAt = createdAt,
modifiedAt = modifiedAt,
replyToMessageId = replyToMessageId.ifEmpty { null },
attachments = attachments.map { attachment ->
Chat.Message.Attachment(
target = attachment.target,
type = when(attachment.type) {
MChatMessageAttachmentType.FILE -> Chat.Message.Attachment.Type.File
MChatMessageAttachmentType.IMAGE -> Chat.Message.Attachment.Type.Image
MChatMessageAttachmentType.LINK -> Chat.Message.Attachment.Type.Link
}
)
},
order = orderId,
reactions = reactions?.reactions?.mapValues { (unicode, identities) ->
identities.ids
} ?: emptyMap()
)
fun MChatMessageContent.core(): Chat.Message.Content = Chat.Message.Content(
text = text,
style = style.toCoreModels(),
marks = marks.map { it.toCoreModels() }
)
fun Rpc.History.Version.toCoreModel(): Version {
return Version(
id = id,

View file

@ -14,6 +14,7 @@ import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Position
import com.anytypeio.anytype.core_models.Relation
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.NameServiceNameType
import com.anytypeio.anytype.core_models.multiplayer.SpaceMemberPermissions
@ -561,6 +562,40 @@ fun MembershipPaymentMethod.toMw(): MMembershipPaymentMethod = when (this) {
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> MMembershipPaymentMethod.MethodInappGoogle
}
fun Chat.Message.mw(): MChatMessage = MChatMessage(
id = id,
message = content?.mw(),
orderId = order,
attachments = attachments.map { it.mw() },
createdAt = createdAt,
modifiedAt = modifiedAt,
creator = creator,
replyToMessageId = replyToMessageId.orEmpty(),
reactions = MChatMessageReactions(
reactions = reactions.mapValues { (unicode, ids) ->
MChatMessageReactionIdentity(
ids = ids
)
}
)
)
fun Chat.Message.Content.mw(): MChatMessageContent = MChatMessageContent(
text = text,
marks = marks.map { it.toMiddlewareModel() },
style = style.toMiddlewareModel()
)
fun Chat.Message.Attachment.mw(): MChatMessageAttachment = MChatMessageAttachment(
target = target,
type = when(type) {
Chat.Message.Attachment.Type.File -> MChatMessageAttachmentType.FILE
Chat.Message.Attachment.Type.Image -> MChatMessageAttachmentType.IMAGE
Chat.Message.Attachment.Type.Link -> MChatMessageAttachmentType.LINK
}
)
fun Rpc.Object.SearchWithMeta.Response.toCoreModelSearchResults(): List<Command.SearchWithMeta.Result> {
return results.map { result ->
Command.SearchWithMeta.Result(
@ -593,4 +628,5 @@ fun Rpc.Object.SearchWithMeta.Response.toCoreModelSearchResults(): List<Command.
}
)
}
}
}

View file

@ -584,4 +584,16 @@ interface MiddlewareService {
@Throws(Exception::class)
fun diffVersions(request: Rpc.History.DiffVersions.Request): Rpc.History.DiffVersions.Response
//endregion
//region CHATS
fun chatAddMessage(request: Rpc.Chat.AddMessage.Request): Rpc.Chat.AddMessage.Response
fun chatEditMessage(request: Rpc.Chat.EditMessageContent.Request): Rpc.Chat.EditMessageContent.Response
fun chatGetMessages(request: Rpc.Chat.GetMessages.Request): Rpc.Chat.GetMessages.Response
fun chatDeleteMessage(request: Rpc.Chat.DeleteMessage.Request): Rpc.Chat.DeleteMessage.Response
fun chatSubscribeLastMessages(request: Rpc.Chat.SubscribeLastMessages.Request): Rpc.Chat.SubscribeLastMessages.Response
fun chatToggleMessageReaction(request: Rpc.Chat.ToggleMessageReaction.Request): Rpc.Chat.ToggleMessageReaction.Response
fun chatUnsubscribe(request: Rpc.Chat.Unsubscribe.Request): Rpc.Chat.Unsubscribe.Response
//endregion
}

View file

@ -2297,4 +2297,97 @@ class MiddlewareServiceImplementation @Inject constructor(
return response
}
}
override fun chatAddMessage(request: Rpc.Chat.AddMessage.Request): Rpc.Chat.AddMessage.Response {
val encoded = Service.chatAddMessage(
Rpc.Chat.AddMessage.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.AddMessage.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.AddMessage.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
override fun chatEditMessage(request: Rpc.Chat.EditMessageContent.Request): Rpc.Chat.EditMessageContent.Response {
val encoded = Service.chatEditMessageContent(
Rpc.Chat.EditMessageContent.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.EditMessageContent.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.EditMessageContent.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
override fun chatDeleteMessage(request: Rpc.Chat.DeleteMessage.Request): Rpc.Chat.DeleteMessage.Response {
val encoded = Service.chatDeleteMessage(
Rpc.Chat.DeleteMessage.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.DeleteMessage.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.DeleteMessage.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
override fun chatGetMessages(request: Rpc.Chat.GetMessages.Request): Rpc.Chat.GetMessages.Response {
val encoded = Service.chatGetMessages(
Rpc.Chat.GetMessages.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.GetMessages.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.GetMessages.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
override fun chatSubscribeLastMessages(request: Rpc.Chat.SubscribeLastMessages.Request): Rpc.Chat.SubscribeLastMessages.Response {
val encoded = Service.chatSubscribeLastMessages(
Rpc.Chat.SubscribeLastMessages.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.SubscribeLastMessages.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.SubscribeLastMessages.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
override fun chatToggleMessageReaction(
request: Rpc.Chat.ToggleMessageReaction.Request
): Rpc.Chat.ToggleMessageReaction.Response {
val encoded = Service.chatToggleMessageReaction(
Rpc.Chat.ToggleMessageReaction.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.ToggleMessageReaction.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.ToggleMessageReaction.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
override fun chatUnsubscribe(request: Rpc.Chat.Unsubscribe.Request): Rpc.Chat.Unsubscribe.Response {
val encoded = Service.chatUnsubscribe(
Rpc.Chat.Unsubscribe.Request.ADAPTER.encode(request)
)
val response = Rpc.Chat.Unsubscribe.Response.ADAPTER.decode(encoded)
val error = response.error
if (error != null && error.code != Rpc.Chat.Unsubscribe.Response.Error.Code.NULL) {
throw Exception(error.description)
} else {
return response
}
}
}

View file

@ -2,6 +2,8 @@ package com.anytypeio.anytype.presentation.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.primitives.SpaceId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@ -14,4 +16,9 @@ open class BaseViewModel : ViewModel() {
companion object {
const val DEFAULT_STOP_TIMEOUT_LIMIT = 5000L
}
data class DefaultParams(
val space: SpaceId,
val ctx: Id
)
}

View file

@ -4436,6 +4436,9 @@ class EditorViewModel(
)
)
}
is OpenObjectNavigation.OpenDiscussion -> {
sendToast("not implemented")
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
sendToast("Unexpected layout: ${navigation.layout}")
}

View file

@ -1359,6 +1359,14 @@ class HomeScreenViewModel(
)
)
}
is OpenObjectNavigation.OpenDiscussion -> {
navigate(
Navigation.OpenDiscussion(
ctx = navigation.target,
space = navigation.space
)
)
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
sendToast("Unexpected layout: ${navigation.layout}")
}
@ -2073,6 +2081,7 @@ class HomeScreenViewModel(
sealed class Navigation {
data class OpenObject(val ctx: Id, val space: Id) : Navigation()
data class OpenDiscussion(val ctx: Id, val space: Id) : Navigation()
data class OpenSet(val ctx: Id, val space: Id, val view: Id?) : Navigation()
data class ExpandWidget(val subscription: Subscription, val space: Id) : Navigation()
data object OpenSpaceSwitcher: Navigation()
@ -2292,6 +2301,7 @@ sealed class OpenObjectNavigation {
data class OpenDataView(val target: Id, val space: Id): OpenObjectNavigation()
data class UnexpectedLayoutError(val layout: ObjectType.Layout?): OpenObjectNavigation()
data object NonValidObject: OpenObjectNavigation()
data class OpenDiscussion(val target: Id, val space: Id): OpenObjectNavigation()
}
fun ObjectWrapper.Basic.navigation() : OpenObjectNavigation {
@ -2334,6 +2344,12 @@ fun ObjectWrapper.Basic.navigation() : OpenObjectNavigation {
space = requireNotNull(spaceId)
)
}
ObjectType.Layout.CHAT -> {
OpenObjectNavigation.OpenDiscussion(
target = id,
space = requireNotNull(spaceId)
)
}
else -> {
OpenObjectNavigation.UnexpectedLayoutError(layout)
}
@ -2374,6 +2390,12 @@ fun ObjectType.Layout.navigation(
space = space
)
}
ObjectType.Layout.CHAT -> {
OpenObjectNavigation.OpenDiscussion(
target = target,
space = space
)
}
else -> {
OpenObjectNavigation.UnexpectedLayoutError(this)
}

View file

@ -235,6 +235,9 @@ class LibraryViewModel(
is OpenObjectNavigation.OpenEditor -> {
navigate(Navigation.OpenEditor(navigation.target))
}
is OpenObjectNavigation.OpenDiscussion -> {
sendToast("not implemented")
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
sendToast("Unexpected layout: ${navigation.layout}")
}

View file

@ -16,7 +16,9 @@ interface AppNavigation {
view: Id? = null,
isPopUpToDashboard: Boolean = false
)
fun openChat(target: Id, space: Id)
fun openDocument(target: Id, space: Id)
fun openDiscussion(target: Id, space: Id)
fun openModalTemplateSelect(
template: Id,
templateTypeId: Id,
@ -66,6 +68,7 @@ interface AppNavigation {
data object ExitFromMigrationScreen : Command()
data class OpenObject(val target: Id, val space: Id) : Command()
data class OpenChat(val target: Id, val space: Id) : Command()
data class LaunchDocument(val target: Id, val space: Id) : Command()
data class OpenModalTemplateSelect(
val template: Id,

View file

@ -56,9 +56,9 @@ class ObjectTypeChangeViewModel(
private val pipeline = combine(searchQuery, setup) { query, setup ->
val recommendedLayouts = if (setup.isWithFiles) {
SupportedLayouts.editorLayouts + SupportedLayouts.fileLayouts
SupportedLayouts.editorLayouts + SupportedLayouts.fileLayouts + listOf(ObjectType.Layout.CHAT)
} else {
SupportedLayouts.editorLayouts
SupportedLayouts.editorLayouts + listOf(ObjectType.Layout.CHAT)
}
val myTypes = proceedWithGettingMyTypes(
query = query,

View file

@ -16,7 +16,8 @@ object SupportedLayouts {
ObjectType.Layout.NOTE,
ObjectType.Layout.BOOKMARK,
ObjectType.Layout.AUDIO,
ObjectType.Layout.PDF
ObjectType.Layout.PDF,
ObjectType.Layout.CHAT
)
val editorLayouts = listOf(
ObjectType.Layout.BASIC,
@ -52,7 +53,8 @@ object SupportedLayouts {
ObjectType.Layout.COLLECTION,
ObjectType.Layout.TODO,
ObjectType.Layout.NOTE,
ObjectType.Layout.BOOKMARK
ObjectType.Layout.BOOKMARK,
ObjectType.Layout.CHAT
)
val addAsLinkToLayouts = editorLayouts + listOf(

View file

@ -66,7 +66,7 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import timber.log.Timber
class GlobalSearchViewModel(
class GlobalSearchViewModel @Inject constructor(
private val vmParams: VmParams,
private val searchWithMeta: SearchWithMeta,
private val storeOfObjectTypes: StoreOfObjectTypes,
@ -499,7 +499,7 @@ class GlobalSearchViewModel(
* @property [title] object title
* @property [type] type screen name
*/
data class GlobalSearchItemView(
data class GlobalSearchItemView(
val id: Id,
val icon: ObjectIcon,
val space: SpaceId,

View file

@ -76,7 +76,7 @@ sealed class ObjectSetCommand {
val selectedTypes: List<Id>
) : Modal()
object OpenEmptyDataViewSelectQueryScreen: Modal()
data object OpenEmptyDataViewSelectQueryScreen: Modal()
data class EditIntrinsicTextRelation(
val ctx: Id,

View file

@ -1527,6 +1527,31 @@ class ObjectSetViewModel(
}
)
}
ObjectType.Layout.CHAT -> {
closeBlock.async(context).fold(
onSuccess = {
navigate(
EventWrapper(
AppNavigation.Command.OpenChat(
target = target,
space = space
)
)
)
},
onFailure = {
Timber.e(it, "Error while closing object set: $context")
navigate(
EventWrapper(
AppNavigation.Command.OpenChat(
target = target,
space = space
)
)
)
}
)
}
else -> {
toast("Unexpected layout: $layout")
Timber.e("Unexpected layout: $layout")

View file

@ -259,6 +259,14 @@ class VaultViewModel(
)
)
}
is OpenObjectNavigation.OpenDiscussion -> {
navigate(
Navigation.OpenChat(
ctx = navigation.target,
space = navigation.space
)
)
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
sendToast("Unexpected layout: ${navigation.layout}")
}
@ -323,6 +331,7 @@ class VaultViewModel(
}
sealed class Navigation {
data class OpenChat(val ctx: Id, val space: Id) : Navigation()
data class OpenObject(val ctx: Id, val space: Id) : Navigation()
data class OpenSet(val ctx: Id, val space: Id, val view: Id?) : Navigation()
}

View file

@ -886,6 +886,14 @@ class CollectionViewModel(
)
)
}
is OpenObjectNavigation.OpenDiscussion -> {
commands.emit(
Command.OpenChat(
target = navigation.target,
space = navigation.space
)
)
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
toasts.emit("Unexpected layout: ${navigation.layout}")
}
@ -990,6 +998,7 @@ class CollectionViewModel(
data class LaunchDocument(val target: Id, val space: Id) : Command()
data class OpenCollection(val subscription: Subscription, val space: Id) : Command()
data class LaunchObjectSet(val target: Id, val space: Id) : Command()
data class OpenChat(val target: Id, val space: Id) : Command()
data object ToDesktop : Command()
data class ToSearch(val space: Id) : Command()