From 4783b8e79e8c4f27341e2aa35f1a54a23fc8712f Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Fri, 23 May 2025 16:18:59 +0200 Subject: [PATCH] DROID-2966 Chats | Tech | ChatPreviewContainer (#2446) --- .../anytype/di/main/SubscriptionsModule.kt | 19 +++ .../anytypeio/anytype/core_models/Event.kt | 11 +- .../data/auth/event/ChatEventRemoteChannel.kt | 1 + .../anytype/domain/chats/ChatContainer.kt | 8 +- .../domain/chats/ChatPreviewContainer.kt | 124 ++++++++++++++++++ .../interactor/MiddlewareEventMapper.kt | 2 +- .../events/ChatEventMiddlewareChannel.kt | 114 +++++++++++++++- 7 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatPreviewContainer.kt diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/SubscriptionsModule.kt b/app/src/main/java/com/anytypeio/anytype/di/main/SubscriptionsModule.kt index 01fa07336f..a79b4dc95e 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/main/SubscriptionsModule.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/main/SubscriptionsModule.kt @@ -8,6 +8,8 @@ import com.anytypeio.anytype.domain.auth.repo.AuthRepository import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers import com.anytypeio.anytype.domain.block.interactor.sets.GetObjectTypes import com.anytypeio.anytype.domain.block.repo.BlockRepository +import com.anytypeio.anytype.domain.chats.ChatEventChannel +import com.anytypeio.anytype.domain.chats.ChatPreviewContainer import com.anytypeio.anytype.domain.config.ConfigStorage import com.anytypeio.anytype.domain.debugging.DebugAccountSelectTrace import com.anytypeio.anytype.domain.debugging.Logger @@ -238,6 +240,23 @@ object SubscriptionsModule { deviceTokenStoringService = deviceTokenStoringService ) + @JvmStatic + @Provides + @Singleton + fun provideChatPreviewContainer( + @Named(DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope, + dispatchers: AppCoroutineDispatchers, + repo: BlockRepository, + logger: Logger, + events: ChatEventChannel + ): ChatPreviewContainer = ChatPreviewContainer.Default( + repo = repo, + dispatchers = dispatchers, + scope = scope, + logger = logger, + events = events + ) + @JvmStatic @Provides @Singleton diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/Event.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/Event.kt index 3f7e8c4355..ef99aa0446 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/Event.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/Event.kt @@ -322,6 +322,9 @@ sealed class Event { sealed class Chats : Command() { + /** + * @property [id] msg ID + */ data class Add( override val context: Id, val id: Id, @@ -329,6 +332,9 @@ sealed class Event { val message: Chat.Message ) : Chats() + /** + * @property [id] msg ID + */ data class Update( override val context: Id, val id: Id, @@ -337,9 +343,12 @@ sealed class Event { data class Delete( override val context: Id, - val id: Id + val message: Id ) : Chats() + /** + * @property [id] msg ID + */ data class UpdateReactions( override val context: Id, val id: Id, diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/event/ChatEventRemoteChannel.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/event/ChatEventRemoteChannel.kt index 9ae50b706b..686c7c2de9 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/event/ChatEventRemoteChannel.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/event/ChatEventRemoteChannel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow interface ChatEventRemoteChannel { fun observe(chat: Id): Flow> + fun subscribe(subscription: Id): Flow> class Default( private val channel: ChatEventRemoteChannel ) : ChatEventChannel { diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt b/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt index 646edef269..e507547656 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt @@ -478,7 +478,7 @@ class ChatContainer @Inject constructor( is Event.Command.Chats.Update -> { if (messageList.isInCurrentWindow(event.id)) { - val index = messageList.indexOfFirst { it.id == event.id } + val index = messageList.indexOfFirst { it.id == event.message.id } messageList[index] = event.message } // Tracking the last message in the chat tail @@ -486,12 +486,12 @@ class ChatContainer @Inject constructor( } is Event.Command.Chats.Delete -> { - if (messageList.isInCurrentWindow(event.id)) { - val index = messageList.indexOfFirst { it.id == event.id } + if (messageList.isInCurrentWindow(event.message)) { + val index = messageList.indexOfFirst { it.id == event.message } messageList.removeAt(index) } // Tracking the last message in the chat tail - lastMessages.remove(event.id) + lastMessages.remove(event.message) } is Event.Command.Chats.UpdateReactions -> { diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatPreviewContainer.kt b/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatPreviewContainer.kt new file mode 100644 index 0000000000..14a0c643e7 --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatPreviewContainer.kt @@ -0,0 +1,124 @@ +package com.anytypeio.anytype.domain.chats + +import com.anytypeio.anytype.core_models.Event +import com.anytypeio.anytype.core_models.chats.Chat +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.block.repo.BlockRepository +import com.anytypeio.anytype.domain.debugging.Logger +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch + +/** + * Cross-space (vault-level) chat previews container + */ +interface ChatPreviewContainer { + + fun start() + fun stop() + + suspend fun getAll(): List + suspend fun getPreview(space: SpaceId): Chat.Preview? + fun observePreviews() : Flow> + + class Default @Inject constructor( + private val repo: BlockRepository, + private val events: ChatEventChannel, + private val dispatchers: AppCoroutineDispatchers, + private val scope: CoroutineScope, + private val logger: Logger + ) : ChatPreviewContainer { + + private var job: Job? = null + private val previews = MutableStateFlow>(emptyList()) + + override fun start() { + job?.cancel() + job = scope.launch(dispatchers.io) { + previews.value = emptyList() + val initial = runCatching { repo.subscribeToMessagePreviews(SUBSCRIPTION_ID) } + .onFailure { logger.logException(it, "DROID-2966 Error while getting initial previews") } + .getOrDefault(emptyList()) + events + .observe(SUBSCRIPTION_ID) + .scan(initial = initial) { previews, events -> + events.fold(previews) { state, event -> + when (event) { + is Event.Command.Chats.Update -> { + state.map { preview -> + if (preview.chat == event.context && preview.message?.id == event.id) { + preview.copy(message = event.message) + } else { + preview + } + } + } + is Event.Command.Chats.UpdateState -> { + state.map { preview -> + if (preview.chat == event.context) { + preview.copy( + state = event.state + ) + } else { + preview + } + } + } + is Event.Command.Chats.Delete -> { + state.map { preview -> + if (preview.chat == event.context && preview.message?.id == event.message) { + preview.copy(message = null) + } else { + preview + } + } + } + else -> state.also { + logger.logInfo("DROID-2966 Ignoring event: $event") + } + } + } + } + .flowOn(dispatchers.io) + .catch { logger.logException(it, "DROID-2966 Exception in chat preview flow") } + .collect { + previews.value = it + } + } + } + + override fun stop() { + job?.cancel() + job = null + scope.launch(dispatchers.io) { + previews.value = emptyList() + runCatching { + repo.unsubscribeFromMessagePreviews(subscription = SUBSCRIPTION_ID) + }.onFailure { + logger.logException(it, "DROID-2966 Error while unsubscribing from message previews") + } + } + } + + override suspend fun getAll(): List = previews.value + + override suspend fun getPreview(space: SpaceId): Chat.Preview? { + return previews.value.firstOrNull { preview -> preview.space.id == space.id } + } + + override fun observePreviews(): Flow> { + return previews + } + + companion object { + private const val SUBSCRIPTION_ID = "chat-previews-subscription" + } + } +} \ No newline at end of file diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/MiddlewareEventMapper.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/MiddlewareEventMapper.kt index 8f72d51be6..54e9d842eb 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/MiddlewareEventMapper.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/MiddlewareEventMapper.kt @@ -299,7 +299,7 @@ fun anytype.Event.Message.toCoreModels( checkNotNull(event) Event.Command.Chats.Delete( context = context, - id = event.id + message = event.id ) } chatUpdate != null -> { diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/ChatEventMiddlewareChannel.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/ChatEventMiddlewareChannel.kt index 1fbdc7082a..9b0831a02f 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/ChatEventMiddlewareChannel.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/ChatEventMiddlewareChannel.kt @@ -24,6 +24,18 @@ class ChatEventMiddlewareChannel( events.isNotEmpty() } } + + override fun subscribe(subscription: Id): Flow> { + return eventProxy + .flow() + .mapNotNull { item -> + item.messages.mapNotNull { msg -> + msg.payload(subscription = subscription, contextId = item.contextId) + } + }.filter { events -> + events.isNotEmpty() + } + } } fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? { @@ -78,7 +90,7 @@ fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? { checkNotNull(event) Event.Command.Chats.Delete( context = contextId, - id = event.id + message = event.id ) } chatUpdateReactions != null -> { @@ -93,6 +105,106 @@ fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? { ) } + else -> { + null + } + } +} + +fun MEventMessage.payload(subscription: Id, contextId: Id) : Event.Command.Chats? { + return when { + chatAdd != null -> { + val event = chatAdd + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.Add( + context = contextId, + order = event.orderId, + id = event.id, + message = requireNotNull(event.message?.core()) + ) + } else { + null + } + } + chatStateUpdate != null -> { + val event = chatStateUpdate + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.UpdateState( + context = contextId, + state = event.state?.core() + ) + } else { + null + } + } + chatUpdateMessageReadStatus != null -> { + val event = chatUpdateMessageReadStatus + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.UpdateMessageReadStatus( + context = contextId, + messages = event.ids, + isRead = event.isRead + ) + } else { + null + } + } + chatUpdateMentionReadStatus != null -> { + val event = chatUpdateMentionReadStatus + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.UpdateMentionReadStatus( + context = contextId, + messages = event.ids, + isRead = event.isRead + ) + } else { + null + } + } + chatUpdate != null -> { + val event = chatUpdate + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.Update( + context = contextId, + id = event.id, + message = requireNotNull(event.message?.core()) + ) + } else { + null + } + } + chatDelete != null -> { + val event = chatDelete + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.Delete( + context = contextId, + message = event.id + ) + } else { + null + } + } + chatUpdateReactions != null -> { + val event = chatUpdateReactions + checkNotNull(event) + if (event.subIds.contains(subscription)) { + Event.Command.Chats.UpdateReactions( + context = contextId, + id = event.id, + reactions = event.reactions?.reactions?.mapValues { (unicode, identities) -> + identities.ids + } ?: emptyMap() + ) + } else { + null + } + } else -> { null }