diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/Conversation.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/Conversation.kt index dab18d3e905..94f6fdcd30e 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/Conversation.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/Conversation.kt @@ -176,6 +176,7 @@ data class Conversation( } enum class ReceiptMode { DISABLED, ENABLED } + enum class TypingIndicatorMode { STARTED, STOPPED } @Suppress("MagicNumber") enum class CipherSuite(val tag: Int) { diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepository.kt new file mode 100644 index 00000000000..525e1c9417d --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepository.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.data.conversation + +import co.touchlab.stately.collections.ConcurrentMutableMap +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.util.safeComputeAndMutateSetValue +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +internal interface TypingIndicatorRepository { + fun addTypingUserInConversation(conversationId: ConversationId, userId: UserId) + fun removeTypingUserInConversation(conversationId: ConversationId, userId: UserId) + suspend fun observeUsersTyping(conversationId: ConversationId): Flow> +} + +internal class TypingIndicatorRepositoryImpl( + private val userTypingCache: ConcurrentMutableMap> +) : TypingIndicatorRepository { + + private val userTypingDataSourceFlow: MutableSharedFlow = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override fun addTypingUserInConversation(conversationId: ConversationId, userId: UserId) { + userTypingCache.safeComputeAndMutateSetValue(conversationId) { ExpiringUserTyping(userId, Clock.System.now()) } + userTypingDataSourceFlow.tryEmit(Unit) + } + + override fun removeTypingUserInConversation(conversationId: ConversationId, userId: UserId) { + userTypingCache.block { entry -> + entry[conversationId]?.apply { this.removeAll { it.userId == userId } } + } + userTypingDataSourceFlow.tryEmit(Unit) + } + + override suspend fun observeUsersTyping(conversationId: ConversationId): Flow> { + return userTypingDataSourceFlow + .map { userTypingCache.filterUsersIdTypingInConversation(conversationId) } + .onStart { emit(userTypingCache.filterUsersIdTypingInConversation(conversationId)) } + .distinctUntilChanged() + } +} + +private fun ConcurrentMutableMap>.filterUsersIdTypingInConversation( + conversationId: ConversationId +) = this[conversationId]?.map { it.userId }?.toSet().orEmpty() + +// todo expire by worker +class ExpiringUserTyping( + val userId: UserId, + val date: Instant +) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/Event.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/Event.kt index bec70996fef..bb143153e01 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/Event.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/Event.kt @@ -24,8 +24,10 @@ import com.wire.kalium.logger.obfuscateDomain import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.client.Client import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.Conversation.Member import com.wire.kalium.logic.data.conversation.Conversation.ReceiptMode +import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.featureConfig.ClassifiedDomainsModel import com.wire.kalium.logic.data.featureConfig.ConferenceCallingModel @@ -361,6 +363,23 @@ sealed class Event(open val id: String, open val transient: Boolean) { ) : Conversation(id, transient, conversationId) { override fun toLogMap(): Map = mapOf(typeKey to "Conversation.CodeDeleted") } + + data class TypingIndicator( + override val id: String, + override val conversationId: ConversationId, + override val transient: Boolean, + val senderUserId: UserId, + val timestampIso: String, + val typingIndicatorMode: TypingIndicatorMode, + ) : Conversation(id, transient, conversationId) { + override fun toLogMap(): Map = mapOf( + typeKey to "Conversation.TypingIndicator", + conversationIdKey to conversationId.toLogString(), + "typingIndicatorMode" to typingIndicatorMode.name, + senderUserIdKey to senderUserId.toLogString(), + timestampIsoKey to timestampIso + ) + } } sealed class Team( diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/EventMapper.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/EventMapper.kt index e8658970f26..c8435c5daaf 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/EventMapper.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/event/EventMapper.kt @@ -34,6 +34,7 @@ import com.wire.kalium.logic.data.id.SubconversationId import com.wire.kalium.logic.data.id.toModel import com.wire.kalium.logic.di.MapperProvider import com.wire.kalium.logic.util.Base64 +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatus import com.wire.kalium.network.api.base.authenticated.featureConfigs.FeatureConfigData import com.wire.kalium.network.api.base.authenticated.notification.EventContentDTO import com.wire.kalium.network.api.base.authenticated.notification.EventResponse @@ -83,6 +84,7 @@ class EventMapper( is EventContentDTO.Unknown -> unknown(id, transient, eventContentDTO) is EventContentDTO.Conversation.AccessUpdate -> unknown(id, transient, eventContentDTO) is EventContentDTO.Conversation.DeletedConversationDTO -> conversationDeleted(id, eventContentDTO, transient) + is EventContentDTO.Conversation.ConversationRenameDTO -> conversationRenamed(id, eventContentDTO, transient) is EventContentDTO.Team.MemberJoin -> teamMemberJoined(id, eventContentDTO, transient) is EventContentDTO.Team.MemberLeave -> teamMemberLeft(id, eventContentDTO, transient) @@ -92,12 +94,32 @@ class EventMapper( is EventContentDTO.UserProperty.PropertiesSetDTO -> updateUserProperties(id, eventContentDTO, transient) is EventContentDTO.UserProperty.PropertiesDeleteDTO -> deleteUserProperties(id, eventContentDTO, transient) is EventContentDTO.Conversation.ReceiptModeUpdate -> conversationReceiptModeUpdate(id, eventContentDTO, transient) + is EventContentDTO.Conversation.MessageTimerUpdate -> conversationMessageTimerUpdate(id, eventContentDTO, transient) + is EventContentDTO.Conversation.CodeDeleted -> conversationCodeDeleted(id, eventContentDTO, transient) is EventContentDTO.Conversation.CodeUpdated -> conversationCodeUpdated(id, eventContentDTO, transient) is EventContentDTO.Federation -> federationTerminated(id, eventContentDTO, transient) + is EventContentDTO.Conversation.ConversationTypingDTO -> conversationTyping(id, eventContentDTO, transient) } + private fun conversationTyping( + id: String, + eventContentDTO: EventContentDTO.Conversation.ConversationTypingDTO, + transient: Boolean + ): Event = + Event.Conversation.TypingIndicator( + id, + eventContentDTO.qualifiedConversation.toModel(), + transient, + eventContentDTO.qualifiedFrom.toModel(), + eventContentDTO.time, + when (eventContentDTO.status.status) { + TypingIndicatorStatus.STARTED -> Conversation.TypingIndicatorMode.STARTED + TypingIndicatorStatus.STOPPED -> Conversation.TypingIndicatorMode.STOPPED + } + ) + private fun federationTerminated(id: String, eventContentDTO: EventContentDTO.Federation, transient: Boolean): Event = when (eventContentDTO) { is EventContentDTO.Federation.FederationConnectionRemovedDTO -> Event.Federation.ConnectionRemoved( diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt index 29e35e2a4fc..298f15243a4 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt @@ -85,8 +85,8 @@ import com.wire.kalium.logic.data.message.CompositeMessageRepository import com.wire.kalium.logic.data.message.IsMessageSentInSelfConversationUseCase import com.wire.kalium.logic.data.message.IsMessageSentInSelfConversationUseCaseImpl import com.wire.kalium.logic.data.message.MessageDataSource -import com.wire.kalium.logic.data.message.MessageMetadataSource import com.wire.kalium.logic.data.message.MessageMetadataRepository +import com.wire.kalium.logic.data.message.MessageMetadataSource import com.wire.kalium.logic.data.message.MessageRepository import com.wire.kalium.logic.data.message.PersistMessageUseCase import com.wire.kalium.logic.data.message.PersistMessageUseCaseImpl @@ -160,8 +160,6 @@ import com.wire.kalium.logic.feature.conversation.ConversationsRecoveryManager import com.wire.kalium.logic.feature.conversation.ConversationsRecoveryManagerImpl import com.wire.kalium.logic.feature.conversation.GetConversationVerificationStatusUseCase import com.wire.kalium.logic.feature.conversation.GetConversationVerificationStatusUseCaseImpl -import com.wire.kalium.logic.feature.conversation.ObserveOtherUserSecurityClassificationLabelUseCase -import com.wire.kalium.logic.feature.conversation.ObserveOtherUserSecurityClassificationLabelUseCaseImpl import com.wire.kalium.logic.feature.conversation.JoinExistingMLSConversationUseCase import com.wire.kalium.logic.feature.conversation.JoinExistingMLSConversationUseCaseImpl import com.wire.kalium.logic.feature.conversation.JoinExistingMLSConversationsUseCase @@ -172,6 +170,8 @@ import com.wire.kalium.logic.feature.conversation.LeaveSubconversationUseCase import com.wire.kalium.logic.feature.conversation.LeaveSubconversationUseCaseImpl import com.wire.kalium.logic.feature.conversation.MLSConversationsRecoveryManager import com.wire.kalium.logic.feature.conversation.MLSConversationsRecoveryManagerImpl +import com.wire.kalium.logic.feature.conversation.ObserveOtherUserSecurityClassificationLabelUseCase +import com.wire.kalium.logic.feature.conversation.ObserveOtherUserSecurityClassificationLabelUseCaseImpl import com.wire.kalium.logic.feature.conversation.ObserveSecurityClassificationLabelUseCase import com.wire.kalium.logic.feature.conversation.ObserveSecurityClassificationLabelUseCaseImpl import com.wire.kalium.logic.feature.conversation.RecoverMLSConversationsUseCase @@ -261,7 +261,6 @@ import com.wire.kalium.logic.functional.map import com.wire.kalium.logic.functional.onSuccess import com.wire.kalium.logic.network.ApiMigrationManager import com.wire.kalium.logic.network.ApiMigrationV3 -import com.wire.kalium.network.NetworkStateObserver import com.wire.kalium.logic.network.SessionManagerImpl import com.wire.kalium.logic.sync.AvsSyncStateReporter import com.wire.kalium.logic.sync.AvsSyncStateReporterImpl @@ -281,8 +280,6 @@ import com.wire.kalium.logic.sync.incremental.IncrementalSyncManager import com.wire.kalium.logic.sync.incremental.IncrementalSyncRecoveryHandlerImpl import com.wire.kalium.logic.sync.incremental.IncrementalSyncWorker import com.wire.kalium.logic.sync.incremental.IncrementalSyncWorkerImpl -import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase -import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCaseImpl import com.wire.kalium.logic.sync.receiver.ConversationEventReceiver import com.wire.kalium.logic.sync.receiver.ConversationEventReceiverImpl import com.wire.kalium.logic.sync.receiver.FeatureConfigEventReceiver @@ -325,18 +322,22 @@ import com.wire.kalium.logic.sync.receiver.conversation.message.NewMessageEventH import com.wire.kalium.logic.sync.receiver.conversation.message.NewMessageEventHandlerImpl import com.wire.kalium.logic.sync.receiver.conversation.message.ProteusMessageUnpacker import com.wire.kalium.logic.sync.receiver.conversation.message.ProteusMessageUnpackerImpl -import com.wire.kalium.logic.sync.receiver.handler.CodeDeletedHandler import com.wire.kalium.logic.sync.receiver.handler.ButtonActionConfirmationHandler import com.wire.kalium.logic.sync.receiver.handler.ButtonActionConfirmationHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.ClearConversationContentHandlerImpl +import com.wire.kalium.logic.sync.receiver.handler.CodeDeletedHandler +import com.wire.kalium.logic.sync.receiver.handler.CodeDeletedHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.CodeUpdateHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.CodeUpdatedHandler -import com.wire.kalium.logic.sync.receiver.handler.CodeDeletedHandlerImpl -import com.wire.kalium.logic.sync.receiver.handler.MessageTextEditHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.DeleteForMeHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.DeleteMessageHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.LastReadContentHandlerImpl +import com.wire.kalium.logic.sync.receiver.handler.MessageTextEditHandlerImpl import com.wire.kalium.logic.sync.receiver.handler.ReceiptMessageHandlerImpl +import com.wire.kalium.logic.sync.receiver.handler.TypingIndicatorHandler +import com.wire.kalium.logic.sync.receiver.handler.TypingIndicatorHandlerImpl +import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase +import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCaseImpl import com.wire.kalium.logic.sync.slow.SlowSlowSyncCriteriaProviderImpl import com.wire.kalium.logic.sync.slow.SlowSyncCriteriaProvider import com.wire.kalium.logic.sync.slow.SlowSyncManager @@ -345,6 +346,7 @@ import com.wire.kalium.logic.sync.slow.SlowSyncRecoveryHandlerImpl import com.wire.kalium.logic.sync.slow.SlowSyncWorker import com.wire.kalium.logic.sync.slow.SlowSyncWorkerImpl import com.wire.kalium.logic.util.MessageContentEncoder +import com.wire.kalium.network.NetworkStateObserver import com.wire.kalium.network.networkContainer.AuthenticatedNetworkContainer import com.wire.kalium.network.session.SessionManager import com.wire.kalium.persistence.client.ClientRegistrationStorage @@ -1154,6 +1156,9 @@ class UserSessionScope internal constructor( conversationDAO = userStorage.database.conversationDAO ) + private val typingIndicatorHandler: TypingIndicatorHandler + get() = TypingIndicatorHandlerImpl(conversations.typingIndicatorRepository) + private val conversationEventReceiver: ConversationEventReceiver by lazy { ConversationEventReceiverImpl( newMessageHandler, @@ -1167,7 +1172,8 @@ class UserSessionScope internal constructor( receiptModeUpdateEventHandler, conversationMessageTimerEventHandler, conversationCodeUpdateHandler, - conversationCodeDeletedHandler + conversationCodeDeletedHandler, + typingIndicatorHandler ) } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt index 91c5fdc3d32..9d74a4ff520 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt @@ -18,6 +18,7 @@ package com.wire.kalium.logic.feature.conversation +import co.touchlab.stately.collections.ConcurrentMutableMap import com.wire.kalium.logic.cache.SelfConversationIdProvider import com.wire.kalium.logic.configuration.server.ServerConfigRepository import com.wire.kalium.logic.data.connection.ConnectionRepository @@ -26,6 +27,7 @@ import com.wire.kalium.logic.data.conversation.ConversationRepository import com.wire.kalium.logic.data.conversation.MLSConversationRepository import com.wire.kalium.logic.data.conversation.NewGroupConversationSystemMessagesCreator import com.wire.kalium.logic.data.conversation.NewGroupConversationSystemMessagesCreatorImpl +import com.wire.kalium.logic.data.conversation.TypingIndicatorRepositoryImpl import com.wire.kalium.logic.data.conversation.UpdateKeyingMaterialThresholdProvider import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.message.PersistMessageUseCase @@ -258,4 +260,10 @@ class ConversationScope internal constructor( serverConfigRepository, selfUserId ) + + internal val typingIndicatorRepository = TypingIndicatorRepositoryImpl(ConcurrentMutableMap()) + + val observeUsersTyping: ObserveUsersTypingUseCase + get() = ObserveUsersTypingUseCaseImpl(typingIndicatorRepository) + } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ObserveUsersTypingUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ObserveUsersTypingUseCase.kt new file mode 100644 index 00000000000..29b8d8cd32e --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ObserveUsersTypingUseCase.kt @@ -0,0 +1,38 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.conversation + +import com.wire.kalium.logic.data.conversation.TypingIndicatorRepository +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import kotlinx.coroutines.flow.Flow + +/** + * Use case for observing current users typing in a given conversation. + */ +interface ObserveUsersTypingUseCase { + suspend fun invoke(conversationId: ConversationId): Flow> +} + +internal class ObserveUsersTypingUseCaseImpl( + private val typingIndicatorRepository: TypingIndicatorRepository +) : ObserveUsersTypingUseCase { + override suspend fun invoke(conversationId: ConversationId): Flow> = + typingIndicatorRepository.observeUsersTyping(conversationId) + +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiver.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiver.kt index 46db9a957c1..10069b19a1e 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiver.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiver.kt @@ -33,6 +33,7 @@ import com.wire.kalium.logic.sync.receiver.conversation.RenamedConversationEvent import com.wire.kalium.logic.sync.receiver.conversation.message.NewMessageEventHandler import com.wire.kalium.logic.sync.receiver.handler.CodeDeletedHandler import com.wire.kalium.logic.sync.receiver.handler.CodeUpdatedHandler +import com.wire.kalium.logic.sync.receiver.handler.TypingIndicatorHandler internal interface ConversationEventReceiver : EventReceiver @@ -51,7 +52,8 @@ internal class ConversationEventReceiverImpl( private val receiptModeUpdateEventHandler: ReceiptModeUpdateEventHandler, private val conversationMessageTimerEventHandler: ConversationMessageTimerEventHandler, private val codeUpdatedHandler: CodeUpdatedHandler, - private val codeDeletedHandler: CodeDeletedHandler + private val codeDeletedHandler: CodeDeletedHandler, + private val typingIndicatorHandler: TypingIndicatorHandler ) : ConversationEventReceiver { override suspend fun onEvent(event: Event.Conversation): Either { // TODO: Make sure errors are accounted for by each handler. @@ -111,6 +113,7 @@ internal class ConversationEventReceiverImpl( is Event.Conversation.ConversationMessageTimer -> conversationMessageTimerEventHandler.handle(event) is Event.Conversation.CodeDeleted -> codeDeletedHandler.handle(event) is Event.Conversation.CodeUpdated -> codeUpdatedHandler.handle(event) + is Event.Conversation.TypingIndicator -> typingIndicatorHandler.handle(event) } } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/handler/TypingIndicatorHandler.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/handler/TypingIndicatorHandler.kt new file mode 100644 index 00000000000..efcfe0906fd --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/handler/TypingIndicatorHandler.kt @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.sync.receiver.handler + +import com.wire.kalium.logic.StorageFailure +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.TypingIndicatorRepository +import com.wire.kalium.logic.data.event.Event +import com.wire.kalium.logic.functional.Either + +internal interface TypingIndicatorHandler { + suspend fun handle(event: Event.Conversation.TypingIndicator): Either +} + +internal class TypingIndicatorHandlerImpl( + private val typingIndicatorRepository: TypingIndicatorRepository +) : TypingIndicatorHandler { + override suspend fun handle(event: Event.Conversation.TypingIndicator): Either { + when (event.typingIndicatorMode) { + Conversation.TypingIndicatorMode.STARTED -> typingIndicatorRepository.addTypingUserInConversation( + event.conversationId, + event.senderUserId + ) + + Conversation.TypingIndicatorMode.STOPPED -> typingIndicatorRepository.removeTypingUserInConversation( + event.conversationId, + event.senderUserId + ) + } + + return Either.Right(Unit) + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/util/CommonUtils.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/util/CommonUtils.kt index 32a5b568f1e..887c6cee181 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/util/CommonUtils.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/util/CommonUtils.kt @@ -94,3 +94,16 @@ fun ConcurrentMutableMap.safeComputeIfAbsent(key: K, f: () -> V): V return@block value } } + +/** + * Convenience method to compute a {K, Set} map mutating the collection with f() if the key is present. + */ +fun ConcurrentMutableMap>.safeComputeAndMutateSetValue(key: K, f: () -> V): MutableSet { + return this.block { + val values = if (this.containsKey(key)) this[key]!! else mutableSetOf() + + values.add(f()) + this[key] = values + return@block values + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepositoryTest.kt new file mode 100644 index 00000000000..da56bda0526 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepositoryTest.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.data.conversation + +import co.touchlab.stately.collections.ConcurrentMutableMap +import com.wire.kalium.logic.framework.TestConversation +import com.wire.kalium.logic.test_util.TestKaliumDispatcher +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TypingIndicatorRepositoryTest { + + private lateinit var typingIndicatorRepository: TypingIndicatorRepository + + @BeforeTest + fun setUp() { + typingIndicatorRepository = TypingIndicatorRepositoryImpl(ConcurrentMutableMap()) + } + + @Test + fun givenUsersInOneConversation_whenTheyAreTyping_thenAddItToTheListOfUsersTypingInConversation() = + runTest(TestKaliumDispatcher.default) { + typingIndicatorRepository.addTypingUserInConversation(conversationOne, expectedUserTypingOne) + typingIndicatorRepository.addTypingUserInConversation(conversationOne, expectedUserTypingTwo) + + assertEquals( + setOf(expectedUserTypingOne, expectedUserTypingTwo), + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull() + ) + } + + @Test + fun givenUsersOneAndTwoTypingInAConversation_whenOneStopped_thenShouldNotBePresentInTypingUsersInConversation() = + runTest(TestKaliumDispatcher.default) { + val expectedUserTyping = setOf(TestConversation.USER_2) + + typingIndicatorRepository.addTypingUserInConversation(conversationOne, TestConversation.USER_1) + typingIndicatorRepository.addTypingUserInConversation(conversationOne, TestConversation.USER_2) + typingIndicatorRepository.removeTypingUserInConversation(conversationOne, TestConversation.USER_1) + + assertEquals( + expectedUserTyping, + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull() + ) + } + + @Test + fun givenMultipleUsersInDifferentConversations_whenTheyAreTyping_thenShouldBePresentInTypingUsersInEachConversation() = + runTest(TestKaliumDispatcher.default) { + typingIndicatorRepository.addTypingUserInConversation(conversationOne, expectedUserTypingOne) + typingIndicatorRepository.addTypingUserInConversation(conversationTwo, expectedUserTypingTwo) + + assertEquals( + setOf(expectedUserTypingOne), + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull() + ) + assertEquals( + setOf(expectedUserTypingTwo), + typingIndicatorRepository.observeUsersTyping(conversationTwo).firstOrNull() + ) + } + + private companion object { + val conversationOne = TestConversation.ID + val conversationTwo = TestConversation.ID.copy(value = "convo-two") + + val expectedUserTypingOne = TestConversation.USER_1 + val expectedUserTypingTwo = TestConversation.USER_2 + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/framework/TestEvent.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/framework/TestEvent.kt index 62be6e5f6bc..9da768d0e5d 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/framework/TestEvent.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/framework/TestEvent.kt @@ -247,4 +247,13 @@ object TestEvent { conversationId = TestConversation.ID, transient = false, ) + + fun typingIndicator(typingIndicatorMode: Conversation.TypingIndicatorMode) = Event.Conversation.TypingIndicator( + id = "eventId", + conversationId = TestConversation.ID, + transient = true, + senderUserId = TestUser.USER_ID, + timestampIso = "2022-03-30T15:36:00.000Z", + typingIndicatorMode = typingIndicatorMode + ) } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiverTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiverTest.kt index 7f60eb6f22b..5a40636e6d9 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiverTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/ConversationEventReceiverTest.kt @@ -33,6 +33,7 @@ import com.wire.kalium.logic.sync.receiver.conversation.NewConversationEventHand import com.wire.kalium.logic.sync.receiver.conversation.ReceiptModeUpdateEventHandler import com.wire.kalium.logic.sync.receiver.conversation.RenamedConversationEventHandler import com.wire.kalium.logic.sync.receiver.conversation.message.NewMessageEventHandler +import com.wire.kalium.logic.sync.receiver.handler.TypingIndicatorHandler import com.wire.kalium.logic.util.arrangement.eventHandler.CodeDeletedHandlerArrangement import com.wire.kalium.logic.util.arrangement.eventHandler.CodeDeletedHandlerArrangementImpl import com.wire.kalium.logic.util.arrangement.eventHandler.CodeUpdatedHandlerArrangement @@ -154,7 +155,8 @@ class ConversationEventReceiverTest { @Test fun givenMemberChangeEvent_whenOnEventInvoked_thenMemberChangeHandlerShouldBeCalled() = runTest { - val memberChangeEvent = TestEvent.memberChange(member = Conversation.Member(TestUser.USER_ID, Conversation.Member.Role.Admin)) + val memberChangeEvent = + TestEvent.memberChange(member = Conversation.Member(TestUser.USER_ID, Conversation.Member.Role.Admin)) val (arrangement, featureConfigEventReceiver) = Arrangement().arrange() @@ -198,19 +200,20 @@ class ConversationEventReceiverTest { } @Test - fun givenConversationReceiptModeEvent_whenOnEventInvoked_thenReceiptModeUpdateEventHandlerShouldBeCalled() = runTest { - val receiptModeUpdateEvent = TestEvent.receiptModeUpdate() + fun givenConversationReceiptModeEvent_whenOnEventInvoked_thenReceiptModeUpdateEventHandlerShouldBeCalled() = + runTest { + val receiptModeUpdateEvent = TestEvent.receiptModeUpdate() - val (arrangement, featureConfigEventReceiver) = Arrangement().arrange() + val (arrangement, featureConfigEventReceiver) = Arrangement().arrange() - val result = featureConfigEventReceiver.onEvent(receiptModeUpdateEvent) + val result = featureConfigEventReceiver.onEvent(receiptModeUpdateEvent) - verify(arrangement.receiptModeUpdateEventHandler) - .suspendFunction(arrangement.receiptModeUpdateEventHandler::handle) - .with(eq(receiptModeUpdateEvent)) - .wasInvoked(once) - result.shouldSucceed() - } + verify(arrangement.receiptModeUpdateEventHandler) + .suspendFunction(arrangement.receiptModeUpdateEventHandler::handle) + .with(eq(receiptModeUpdateEvent)) + .wasInvoked(once) + result.shouldSucceed() + } @Test fun givenAccessUpdateEvent_whenOnEventInvoked_thenReturnSuccess() = runTest { @@ -224,22 +227,23 @@ class ConversationEventReceiverTest { } @Test - fun givenConversationMessageTimerEvent_whenOnEventInvoked_thenPropagateConversationMessageTimerEventHandlerResult() = runTest { - val conversationMessageTimerEvent = TestEvent.timerChanged() + fun givenConversationMessageTimerEvent_whenOnEventInvoked_thenPropagateConversationMessageTimerEventHandlerResult() = + runTest { + val conversationMessageTimerEvent = TestEvent.timerChanged() - val (arrangement, featureConfigEventReceiver) = Arrangement() - .withConversationMessageTimerFailed() - .arrange() + val (arrangement, featureConfigEventReceiver) = Arrangement() + .withConversationMessageTimerFailed() + .arrange() - val result = featureConfigEventReceiver.onEvent(conversationMessageTimerEvent) + val result = featureConfigEventReceiver.onEvent(conversationMessageTimerEvent) - verify(arrangement.conversationMessageTimerEventHandler) - .suspendFunction(arrangement.conversationMessageTimerEventHandler::handle) - .with(eq(conversationMessageTimerEvent)) - .wasInvoked(once) + verify(arrangement.conversationMessageTimerEventHandler) + .suspendFunction(arrangement.conversationMessageTimerEventHandler::handle) + .with(eq(conversationMessageTimerEvent)) + .wasInvoked(once) - result.shouldFail() - } + result.shouldFail() + } @Test fun givenCodeUpdateEventAndHandlingSuccess_whenOnEventInvoked_thenPropagateCodeUpdatedHandlerResult() = runTest { @@ -321,6 +325,38 @@ class ConversationEventReceiverTest { result.shouldFail() } + @Test + fun givenTypingEventAndHandlingSucceeds_whenOnEventInvoked_thenSuccessHandlerResult() = runTest { + val typingStarted = TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STARTED) + val (arrangement, handler) = Arrangement() + .withConversationTypingEventSucceeded(Either.Right(Unit)) + .arrange() + + val result = handler.onEvent(typingStarted) + + verify(arrangement.typingIndicatorHandler) + .suspendFunction(arrangement.typingIndicatorHandler::handle) + .with(eq(typingStarted)) + .wasInvoked(once) + result.shouldSucceed() + } + + @Test + fun givenTypingEventAndHandlingFails_whenOnEventInvoked_thenSuccessHandlerPropagateFails() = runTest { + val typingStarted = TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STARTED) + val (arrangement, handler) = Arrangement() + .withConversationTypingEventSucceeded(Either.Left(StorageFailure.Generic(RuntimeException("some error")))) + .arrange() + + val result = handler.onEvent(typingStarted) + + verify(arrangement.typingIndicatorHandler) + .suspendFunction(arrangement.typingIndicatorHandler::handle) + .with(eq(typingStarted)) + .wasInvoked(once) + result.shouldFail() + } + private class Arrangement : CodeUpdatedHandlerArrangement by CodeUpdatedHandlerArrangementImpl(), CodeDeletedHandlerArrangement by CodeDeletedHandlerArrangementImpl() { @@ -355,6 +391,9 @@ class ConversationEventReceiverTest { @Mock val deletedConversationEventHandler = mock(classOf()) + @Mock + val typingIndicatorHandler = mock(classOf()) + private val conversationEventReceiver: ConversationEventReceiver = ConversationEventReceiverImpl( newMessageHandler = newMessageEventHandler, newConversationHandler = newConversationEventHandler, @@ -367,7 +406,8 @@ class ConversationEventReceiverTest { receiptModeUpdateEventHandler = receiptModeUpdateEventHandler, conversationMessageTimerEventHandler = conversationMessageTimerEventHandler, codeUpdatedHandler = codeUpdatedHandler, - codeDeletedHandler = codeDeletedHandler + codeDeletedHandler = codeDeletedHandler, + typingIndicatorHandler = typingIndicatorHandler ) fun arrange(block: Arrangement.() -> Unit = {}) = apply(block).run { @@ -394,6 +434,13 @@ class ConversationEventReceiverTest { .whenInvokedWith(any()) .thenReturn(Either.Left(failure)) } + + fun withConversationTypingEventSucceeded(result: Either) = apply { + given(typingIndicatorHandler) + .suspendFunction(typingIndicatorHandler::handle) + .whenInvokedWith(any()) + .thenReturn(result) + } } companion object { diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/handler/TypingIndicatorHandlerTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/handler/TypingIndicatorHandlerTest.kt new file mode 100644 index 00000000000..1a5368cce14 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/handler/TypingIndicatorHandlerTest.kt @@ -0,0 +1,83 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.sync.receiver.handler + +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.TypingIndicatorRepository +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.framework.TestConversation +import com.wire.kalium.logic.framework.TestEvent +import com.wire.kalium.logic.framework.TestUser +import com.wire.kalium.logic.util.shouldSucceed +import io.mockative.Mock +import io.mockative.eq +import io.mockative.given +import io.mockative.mock +import io.mockative.once +import io.mockative.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class TypingIndicatorHandlerTest { + + @Test + fun givenTypingEvent_whenIsModeStarted_thenHandleToAdd() = runTest { + val (arrangement, handler) = Arrangement() + .withTypingIndicatorObserve(setOf(TestUser.USER_ID)) + .arrange() + + val result = handler.handle(TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STARTED)) + + result.shouldSucceed() + verify(arrangement.typingIndicatorRepository) + .function(arrangement.typingIndicatorRepository::addTypingUserInConversation) + .with(eq(TestConversation.ID), eq(TestUser.USER_ID)) + .wasInvoked(once) + } + + @Test + fun givenTypingEvent_whenIsModeStopped_thenHandleToRemove() = runTest { + val (arrangement, handler) = Arrangement() + .withTypingIndicatorObserve(setOf(TestUser.USER_ID)) + .arrange() + + val result = handler.handle(TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STOPPED)) + + result.shouldSucceed() + verify(arrangement.typingIndicatorRepository) + .function(arrangement.typingIndicatorRepository::removeTypingUserInConversation) + .with(eq(TestConversation.ID), eq(TestUser.USER_ID)) + .wasInvoked(once) + } + + private class Arrangement { + @Mock + val typingIndicatorRepository: TypingIndicatorRepository = mock(TypingIndicatorRepository::class) + + fun withTypingIndicatorObserve(usersId: Set) = apply { + given(typingIndicatorRepository) + .suspendFunction(typingIndicatorRepository::observeUsersTyping) + .whenInvokedWith(eq(TestConversation.ID)) + .thenReturn(flowOf(usersId)) + } + + fun arrange() = this to TypingIndicatorHandlerImpl(typingIndicatorRepository) + } + +} diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/TypingIndicatorStatusDTO.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/TypingIndicatorStatusDTO.kt new file mode 100644 index 00000000000..757ad5b9194 --- /dev/null +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/TypingIndicatorStatusDTO.kt @@ -0,0 +1,33 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.network.api.base.authenticated.conversation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TypingIndicatorStatusDTO(@SerialName("status") val status: TypingIndicatorStatus) + +@Serializable +enum class TypingIndicatorStatus(val value: String) { + @SerialName("started") + STARTED("started"), + + @SerialName("stopped") + STOPPED("stopped") +} diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/notification/EventContentDTO.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/notification/EventContentDTO.kt index ec8ffe1812d..41b02bd58ab 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/notification/EventContentDTO.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/notification/EventContentDTO.kt @@ -25,6 +25,7 @@ import com.wire.kalium.network.api.base.authenticated.conversation.ConversationN import com.wire.kalium.network.api.base.authenticated.conversation.ConversationResponse import com.wire.kalium.network.api.base.authenticated.conversation.ConversationRoleChange import com.wire.kalium.network.api.base.authenticated.conversation.ConversationUsers +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO import com.wire.kalium.network.api.base.authenticated.conversation.guestroomlink.ConversationInviteLinkResponse import com.wire.kalium.network.api.base.authenticated.conversation.messagetimer.ConversationMessageTimerDTO import com.wire.kalium.network.api.base.authenticated.conversation.model.ConversationAccessInfoDTO @@ -189,7 +190,15 @@ sealed class EventContentDTO { @SerialName("data") val roleChange: ConversationRoleChange ) : Conversation() - // TODO conversation.typing + @Serializable + @SerialName("conversation.typing") + data class ConversationTypingDTO( + @SerialName("qualified_conversation") val qualifiedConversation: ConversationId, + @SerialName("qualified_from") val qualifiedFrom: UserId, + val time: String, + @SerialName("from") val from: String, + @SerialName("data") val status: TypingIndicatorStatusDTO, + ) : Conversation() @Serializable @SerialName("conversation.otr-message-add")