diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt index ce947859fcc..b5550138239 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt @@ -238,6 +238,10 @@ interface ConversationRepository { suspend fun getConversationDetailsByMLSGroupId(mlsGroupId: GroupID): Either suspend fun observeUnreadArchivedConversationsCount(): Flow + suspend fun sendTypingIndicatorStatus( + conversationId: ConversationId, + typingStatus: Conversation.TypingIndicatorMode + ): Either } @Suppress("LongParameterList", "TooManyFunctions") @@ -869,6 +873,13 @@ internal class ConversationDataSource internal constructor( .wrapStorageRequest() .mapToRightOr(0L) + override suspend fun sendTypingIndicatorStatus( + conversationId: ConversationId, + typingStatus: Conversation.TypingIndicatorMode + ): Either = wrapApiRequest { + conversationApi.sendTypingIndicatorNotification(conversationId.toApi(), typingStatus.toStatusDto()) + } + private suspend fun persistIncompleteConversations( conversationsFailed: List ): Either { 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/TypingIndicatorIncomingRepository.kt similarity index 76% rename from logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepository.kt rename to logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorIncomingRepository.kt index c3328debeb3..6177e2920ca 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorIncomingRepository.kt @@ -27,26 +27,25 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -internal interface TypingIndicatorRepository { +internal interface TypingIndicatorIncomingRepository { suspend fun addTypingUserInConversation(conversationId: ConversationId, userId: UserId) suspend fun removeTypingUserInConversation(conversationId: ConversationId, userId: UserId) - suspend fun observeUsersTyping(conversationId: ConversationId): Flow> + suspend fun observeUsersTyping(conversationId: ConversationId): Flow> + suspend fun clearExpiredTypingIndicators() } -internal class TypingIndicatorRepositoryImpl( - private val userTypingCache: ConcurrentMutableMap>, +internal class TypingIndicatorIncomingRepositoryImpl( + private val userTypingCache: ConcurrentMutableMap>, private val userPropertyRepository: UserPropertyRepository -) : TypingIndicatorRepository { +) : TypingIndicatorIncomingRepository { private val userTypingDataSourceFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE, onBufferOverflow = BufferOverflow.DROP_OLDEST) override suspend fun addTypingUserInConversation(conversationId: ConversationId, userId: UserId) { if (userPropertyRepository.getTypingIndicatorStatus()) { - userTypingCache.safeComputeAndMutateSetValue(conversationId) { ExpiringUserTyping(userId, Clock.System.now()) } + userTypingCache.safeComputeAndMutateSetValue(conversationId) { userId } .also { userTypingDataSourceFlow.tryEmit(Unit) } @@ -55,36 +54,27 @@ internal class TypingIndicatorRepositoryImpl( override suspend fun removeTypingUserInConversation(conversationId: ConversationId, userId: UserId) { userTypingCache.block { entry -> - entry[conversationId]?.apply { this.removeAll { it.userId == userId } } + entry[conversationId]?.apply { this.removeAll { it == userId } } }.also { userTypingDataSourceFlow.tryEmit(Unit) } } - override suspend fun observeUsersTyping(conversationId: ConversationId): Flow> { + override suspend fun observeUsersTyping(conversationId: ConversationId): Flow> { return userTypingDataSourceFlow .map { userTypingCache[conversationId] ?: emptySet() } .onStart { emit(userTypingCache[conversationId] ?: emptySet()) } } - companion object { - const val BUFFER_SIZE = 32 // drop after this threshold - } -} - -// todo expire by worker -data class ExpiringUserTyping( - val userId: UserId, - val date: Instant -) { - override fun equals(other: Any?): Boolean { - return other != null && when (other) { - is ExpiringUserTyping -> other.userId == this.userId - else -> false + override suspend fun clearExpiredTypingIndicators() { + userTypingCache.block { entry -> + entry.clear() + }.also { + userTypingDataSourceFlow.tryEmit(Unit) } } - override fun hashCode(): Int { - return this.userId.hashCode() + companion object { + const val BUFFER_SIZE = 32 // drop after this threshold } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorOutgoingRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorOutgoingRepository.kt new file mode 100644 index 00000000000..91b08863df4 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorOutgoingRepository.kt @@ -0,0 +1,53 @@ +/* + * 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 com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.properties.UserPropertyRepository +import com.wire.kalium.logic.functional.Either + +internal interface TypingIndicatorOutgoingRepository { + suspend fun sendTypingIndicatorStatus( + conversationId: ConversationId, + typingStatus: Conversation.TypingIndicatorMode + ): Either + +} + +internal class TypingIndicatorOutgoingRepositoryImpl( + private val typingIndicatorSenderHandler: TypingIndicatorSenderHandler, + private val userPropertyRepository: UserPropertyRepository +) : TypingIndicatorOutgoingRepository { + + override suspend fun sendTypingIndicatorStatus( + conversationId: ConversationId, + typingStatus: Conversation.TypingIndicatorMode + ): Either { + if (userPropertyRepository.getTypingIndicatorStatus()) { + when (typingStatus) { + Conversation.TypingIndicatorMode.STARTED -> + typingIndicatorSenderHandler.sendStartedAndEnqueueStoppingEvent(conversationId) + + Conversation.TypingIndicatorMode.STOPPED -> typingIndicatorSenderHandler.sendStoppingEvent(conversationId) + } + } + return Either.Right(Unit) + } + +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorSenderHandler.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorSenderHandler.kt new file mode 100644 index 00000000000..702edfe205e --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorSenderHandler.kt @@ -0,0 +1,108 @@ +/* + * 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 com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.functional.fold +import com.wire.kalium.logic.kaliumLogger +import com.wire.kalium.util.KaliumDispatcher +import com.wire.kalium.util.KaliumDispatcherImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * Outgoing user typing sent events manager. + * + * - It will send started and stopped events. + * - For each started sent event, will 'enqueue' a stopped event after a timeout. + * + */ +internal interface TypingIndicatorSenderHandler { + fun sendStoppingEvent(conversationId: ConversationId) + fun sendStartedAndEnqueueStoppingEvent(conversationId: ConversationId) +} + +internal class TypingIndicatorSenderHandlerImpl( + private val conversationRepository: ConversationRepository, + private val kaliumDispatcher: KaliumDispatcher = KaliumDispatcherImpl, + userSessionCoroutineScope: CoroutineScope +) : TypingIndicatorSenderHandler, CoroutineScope by userSessionCoroutineScope { + override val coroutineContext: CoroutineContext + get() = kaliumDispatcher.default + + private val outgoingStoppedQueueTypingEventsMutex = Mutex() + private val outgoingStoppedQueueTypingEvents = mutableMapOf() + private val typingIndicatorTimeoutInSeconds = 10.toDuration(DurationUnit.SECONDS) + + /** + * Sends a stopping event and removes it from the 'queue'. + */ + override fun sendStoppingEvent(conversationId: ConversationId) { + launch { + outgoingStoppedQueueTypingEventsMutex.withLock { + if (!outgoingStoppedQueueTypingEvents.containsKey(conversationId)) { + return@launch + } + outgoingStoppedQueueTypingEvents.remove(conversationId) + sendTypingIndicatorStatus(conversationId, Conversation.TypingIndicatorMode.STOPPED) + } + } + } + + /** + * Sends a started event and enqueues a stopping event if sent successfully. + */ + override fun sendStartedAndEnqueueStoppingEvent(conversationId: ConversationId) { + launch { + val (_, isStartedSent) = outgoingStoppedQueueTypingEventsMutex.withLock { + if (outgoingStoppedQueueTypingEvents.containsKey(conversationId)) { + return@launch + } + val isSent = sendTypingIndicatorStatus(conversationId, Conversation.TypingIndicatorMode.STARTED) + this to isSent + } + + if (isStartedSent) { + enqueueStoppedEvent(conversationId) + } + } + } + + private suspend fun enqueueStoppedEvent(conversationId: ConversationId) { + outgoingStoppedQueueTypingEvents[conversationId] = Unit + delay(typingIndicatorTimeoutInSeconds) + sendStoppingEvent(conversationId) + } + + private suspend fun sendTypingIndicatorStatus( + conversationId: ConversationId, + typingStatus: Conversation.TypingIndicatorMode + ): Boolean = conversationRepository.sendTypingIndicatorStatus(conversationId, typingStatus).fold({ + kaliumLogger.w("Skipping failed to send typing indicator status: $typingStatus") + false + }) { + kaliumLogger.i("Successfully sent typing started indicator status: $typingStatus") + true + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorStatusMapper.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorStatusMapper.kt new file mode 100644 index 00000000000..752eca14854 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorStatusMapper.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.logic.data.conversation + +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatus +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO + +fun TypingIndicatorStatus.toModel(): Conversation.TypingIndicatorMode = when (this) { + TypingIndicatorStatus.STARTED -> Conversation.TypingIndicatorMode.STARTED + TypingIndicatorStatus.STOPPED -> Conversation.TypingIndicatorMode.STOPPED +} + +fun Conversation.TypingIndicatorMode.toApi(): TypingIndicatorStatus = when (this) { + Conversation.TypingIndicatorMode.STARTED -> TypingIndicatorStatus.STARTED + Conversation.TypingIndicatorMode.STOPPED -> TypingIndicatorStatus.STOPPED +} + +fun Conversation.TypingIndicatorMode.toStatusDto(): TypingIndicatorStatusDTO = TypingIndicatorStatusDTO(this.toApi()) 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 c8435c5daaf..c915ca50684 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 @@ -27,6 +27,7 @@ import com.wire.kalium.logic.data.conversation.ConversationRoleMapper import com.wire.kalium.logic.data.conversation.MemberMapper import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.conversation.ReceiptModeMapper +import com.wire.kalium.logic.data.conversation.toModel import com.wire.kalium.logic.data.event.Event.UserProperty.ReadReceiptModeSet import com.wire.kalium.logic.data.event.Event.UserProperty.TypingIndicatorModeSet import com.wire.kalium.logic.data.featureConfig.FeatureConfigMapper @@ -34,7 +35,6 @@ 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 @@ -114,10 +114,7 @@ class EventMapper( transient, eventContentDTO.qualifiedFrom.toModel(), eventContentDTO.time, - when (eventContentDTO.status.status) { - TypingIndicatorStatus.STARTED -> Conversation.TypingIndicatorMode.STARTED - TypingIndicatorStatus.STOPPED -> Conversation.TypingIndicatorMode.STOPPED - } + eventContentDTO.status.status.toModel() ) private fun federationTerminated(id: String, eventContentDTO: EventContentDTO.Federation, transient: Boolean): Event = @@ -234,6 +231,7 @@ class EventMapper( ) } } + else -> unknown( id = id, transient = transient, 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 04035d9821f..c1d9b125ac4 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 @@ -156,8 +156,6 @@ import com.wire.kalium.logic.feature.connection.SyncConnectionsUseCaseImpl import com.wire.kalium.logic.feature.conversation.ConversationScope import com.wire.kalium.logic.feature.conversation.ConversationsRecoveryManager import com.wire.kalium.logic.feature.conversation.ConversationsRecoveryManagerImpl -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 @@ -170,17 +168,22 @@ import com.wire.kalium.logic.feature.conversation.MLSConversationsRecoveryManage import com.wire.kalium.logic.feature.conversation.MLSConversationsRecoveryManagerImpl import com.wire.kalium.logic.feature.conversation.MLSConversationsVerificationStatusesHandler import com.wire.kalium.logic.feature.conversation.MLSConversationsVerificationStatusesHandlerImpl +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 import com.wire.kalium.logic.feature.conversation.RecoverMLSConversationsUseCaseImpl import com.wire.kalium.logic.feature.conversation.SyncConversationsUseCase import com.wire.kalium.logic.feature.conversation.SyncConversationsUseCaseImpl +import com.wire.kalium.logic.feature.conversation.TypingIndicatorSyncManager import com.wire.kalium.logic.feature.conversation.keyingmaterials.KeyingMaterialsManager import com.wire.kalium.logic.feature.conversation.keyingmaterials.KeyingMaterialsManagerImpl import com.wire.kalium.logic.feature.debug.DebugScope import com.wire.kalium.logic.feature.e2ei.EnrollE2EIUseCase import com.wire.kalium.logic.feature.e2ei.EnrollE2EIUseCaseImpl +import com.wire.kalium.logic.feature.featureConfig.SyncFeatureConfigsUseCase +import com.wire.kalium.logic.feature.featureConfig.SyncFeatureConfigsUseCaseImpl import com.wire.kalium.logic.feature.featureConfig.handler.AppLockConfigHandler import com.wire.kalium.logic.feature.featureConfig.handler.ClassifiedDomainsConfigHandler import com.wire.kalium.logic.feature.featureConfig.handler.ConferenceCallingConfigHandler @@ -190,8 +193,6 @@ import com.wire.kalium.logic.feature.featureConfig.handler.GuestRoomConfigHandle import com.wire.kalium.logic.feature.featureConfig.handler.MLSConfigHandler import com.wire.kalium.logic.feature.featureConfig.handler.SecondFactorPasswordChallengeConfigHandler import com.wire.kalium.logic.feature.featureConfig.handler.SelfDeletingMessagesConfigHandler -import com.wire.kalium.logic.feature.featureConfig.SyncFeatureConfigsUseCase -import com.wire.kalium.logic.feature.featureConfig.SyncFeatureConfigsUseCaseImpl import com.wire.kalium.logic.feature.keypackage.KeyPackageManager import com.wire.kalium.logic.feature.keypackage.KeyPackageManagerImpl import com.wire.kalium.logic.feature.message.AddSystemMessageToAllConversationsUseCase @@ -1164,7 +1165,7 @@ class UserSessionScope internal constructor( ) private val typingIndicatorHandler: TypingIndicatorHandler - get() = TypingIndicatorHandlerImpl(userId, conversations.typingIndicatorRepository) + get() = TypingIndicatorHandlerImpl(userId, conversations.typingIndicatorIncomingRepository) private val conversationEventReceiver: ConversationEventReceiver by lazy { ConversationEventReceiverImpl( @@ -1534,6 +1535,9 @@ class UserSessionScope internal constructor( MLSConversationsVerificationStatusesHandlerImpl(conversationRepository, persistMessage, mlsConversationRepository, userId) } + private val typingIndicatorSyncManager: TypingIndicatorSyncManager = + TypingIndicatorSyncManager(lazy { conversations.typingIndicatorIncomingRepository }, observeSyncState) + init { launch { apiMigrationManager.performMigrations() @@ -1573,6 +1577,10 @@ class UserSessionScope internal constructor( launch { mlsConversationsVerificationStatusesHandler.invoke() } + + launch { + typingIndicatorSyncManager.execute() + } } fun onDestroy() { diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ClearUsersTypingEventsUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ClearUsersTypingEventsUseCase.kt new file mode 100644 index 00000000000..08db619803e --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ClearUsersTypingEventsUseCase.kt @@ -0,0 +1,41 @@ +/* + * 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.TypingIndicatorIncomingRepository +import com.wire.kalium.util.KaliumDispatcher +import com.wire.kalium.util.KaliumDispatcherImpl +import kotlinx.coroutines.withContext + +/** + * Use case for clearing and drop orphaned typing indicators + */ +interface ClearUsersTypingEventsUseCase { + suspend operator fun invoke() +} + +internal class ClearUsersTypingEventsUseCaseImpl( + private val typingIndicatorIncomingRepository: TypingIndicatorIncomingRepository, + private val dispatcher: KaliumDispatcher = KaliumDispatcherImpl +) : ClearUsersTypingEventsUseCase { + override suspend operator fun invoke() { + withContext(dispatcher.io) { + typingIndicatorIncomingRepository.clearExpiredTypingIndicators() + } + } +} 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 7e07482cadb..8885c74faf5 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 @@ -27,7 +27,10 @@ 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.TypingIndicatorIncomingRepositoryImpl +import com.wire.kalium.logic.data.conversation.TypingIndicatorOutgoingRepositoryImpl +import com.wire.kalium.logic.data.conversation.TypingIndicatorSenderHandler +import com.wire.kalium.logic.data.conversation.TypingIndicatorSenderHandlerImpl import com.wire.kalium.logic.data.conversation.UpdateKeyingMaterialThresholdProvider import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.message.PersistMessageUseCase @@ -266,9 +269,28 @@ class ConversationScope internal constructor( val observeArchivedUnreadConversationsCount: ObserveArchivedUnreadConversationsCountUseCase get() = ObserveArchivedUnreadConversationsCountUseCaseImpl(conversationRepository) - internal val typingIndicatorRepository = TypingIndicatorRepositoryImpl(ConcurrentMutableMap(), userPropertyRepository) + private val typingIndicatorSenderHandler: TypingIndicatorSenderHandler = + TypingIndicatorSenderHandlerImpl(conversationRepository = conversationRepository, userSessionCoroutineScope = scope) + + internal val typingIndicatorIncomingRepository = + TypingIndicatorIncomingRepositoryImpl( + ConcurrentMutableMap(), + userPropertyRepository + ) + + internal val typingIndicatorOutgoingRepository = + TypingIndicatorOutgoingRepositoryImpl( + typingIndicatorSenderHandler, + userPropertyRepository + ) + + val sendTypingEvent: SendTypingEventUseCase + get() = SendTypingEventUseCaseImpl(typingIndicatorOutgoingRepository) val observeUsersTyping: ObserveUsersTypingUseCase - get() = ObserveUsersTypingUseCaseImpl(typingIndicatorRepository, userRepository) + get() = ObserveUsersTypingUseCaseImpl(typingIndicatorIncomingRepository, userRepository) + + val clearUsersTypingEvents: ClearUsersTypingEventsUseCase + get() = ClearUsersTypingEventsUseCaseImpl(typingIndicatorIncomingRepository) } 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 index e7a1e9ee880..abc7dabe6ee 100644 --- 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 @@ -17,7 +17,7 @@ */ package com.wire.kalium.logic.feature.conversation -import com.wire.kalium.logic.data.conversation.TypingIndicatorRepository +import com.wire.kalium.logic.data.conversation.TypingIndicatorIncomingRepository import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.UserSummary import com.wire.kalium.logic.data.user.UserRepository @@ -38,13 +38,13 @@ interface ObserveUsersTypingUseCase { } internal class ObserveUsersTypingUseCaseImpl( - private val typingIndicatorRepository: TypingIndicatorRepository, + private val typingIndicatorIncomingRepository: TypingIndicatorIncomingRepository, private val userRepository: UserRepository, private val dispatcher: KaliumDispatcher = KaliumDispatcherImpl ) : ObserveUsersTypingUseCase { override suspend operator fun invoke(conversationId: ConversationId): Flow> = withContext(dispatcher.io) { - typingIndicatorRepository.observeUsersTyping(conversationId).map { usersEntries -> - userRepository.getUsersSummaryByIds(usersEntries.map { it.userId }).fold({ + typingIndicatorIncomingRepository.observeUsersTyping(conversationId).map { usersEntries -> + userRepository.getUsersSummaryByIds(usersEntries.map { it }).fold({ kaliumLogger.w("Users not found locally, skipping... $it") emptySet() }, { it.toSet() }) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/SendTypingEventUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/SendTypingEventUseCase.kt new file mode 100644 index 00000000000..d1c49e02790 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/SendTypingEventUseCase.kt @@ -0,0 +1,44 @@ +/* + * 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.Conversation +import com.wire.kalium.logic.data.conversation.TypingIndicatorOutgoingRepository +import com.wire.kalium.logic.data.id.ConversationId + +/** + * UseCase for sending a typing event to a specific [ConversationId] + * + * Underlying implementation will take care of enqueuing the event for a [Conversation.TypingIndicatorMode.STOPPED] + * after a certain amount of time. + * + */ +interface SendTypingEventUseCase { + suspend operator fun invoke( + conversationId: ConversationId, + typingStatus: Conversation.TypingIndicatorMode + ) +} + +internal class SendTypingEventUseCaseImpl( + private val typingIndicatorRepository: TypingIndicatorOutgoingRepository +) : SendTypingEventUseCase { + override suspend fun invoke(conversationId: ConversationId, typingStatus: Conversation.TypingIndicatorMode) { + typingIndicatorRepository.sendTypingIndicatorStatus(conversationId, typingStatus) + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/TypingIndicatorSyncManager.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/TypingIndicatorSyncManager.kt new file mode 100644 index 00000000000..aed94d8eee6 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/TypingIndicatorSyncManager.kt @@ -0,0 +1,39 @@ +/* + * 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.TypingIndicatorIncomingRepository +import com.wire.kalium.logic.kaliumLogger +import com.wire.kalium.logic.sync.ObserveSyncStateUseCase +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged + +internal class TypingIndicatorSyncManager( + private val typingIndicatorIncomingRepository: Lazy, + private val observeSyncStateUseCase: ObserveSyncStateUseCase +) { + /** + * Periodically clears and drop orphaned typing indicators, so we don't keep them forever. + */ + suspend fun execute() { + observeSyncStateUseCase().distinctUntilChanged().collectLatest { + kaliumLogger.d("Starting clear of orphaned typing indicators...") + typingIndicatorIncomingRepository.value.clearExpiredTypingIndicators() + } + } +} 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 index 6242cde9899..158c2b8dd27 100644 --- 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 @@ -20,7 +20,7 @@ package com.wire.kalium.logic.sync.receiver.handler import com.wire.kalium.logger.KaliumLogger.Companion.ApplicationFlow.EVENT_RECEIVER 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.conversation.TypingIndicatorIncomingRepository import com.wire.kalium.logic.data.event.Event import com.wire.kalium.logic.data.event.EventLoggingStatus import com.wire.kalium.logic.data.event.logEventProcessing @@ -34,7 +34,7 @@ internal interface TypingIndicatorHandler { internal class TypingIndicatorHandlerImpl( private val selfUserId: UserId, - private val typingIndicatorRepository: TypingIndicatorRepository + private val typingIndicatorIncomingRepository: TypingIndicatorIncomingRepository ) : TypingIndicatorHandler { override suspend fun handle(event: Event.Conversation.TypingIndicator): Either { if (event.senderUserId == selfUserId) { @@ -43,12 +43,12 @@ internal class TypingIndicatorHandlerImpl( } when (event.typingIndicatorMode) { - Conversation.TypingIndicatorMode.STARTED -> typingIndicatorRepository.addTypingUserInConversation( + Conversation.TypingIndicatorMode.STARTED -> typingIndicatorIncomingRepository.addTypingUserInConversation( event.conversationId, event.senderUserId ) - Conversation.TypingIndicatorMode.STOPPED -> typingIndicatorRepository.removeTypingUserInConversation( + Conversation.TypingIndicatorMode.STOPPED -> typingIndicatorIncomingRepository.removeTypingUserInConversation( event.conversationId, event.senderUserId ) 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/TypingIndicatorIncomingRepositoryTest.kt similarity index 81% rename from logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepositoryTest.kt rename to logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorIncomingRepositoryTest.kt index 7e2171ef149..2ca01b4fd80 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorRepositoryTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorIncomingRepositoryTest.kt @@ -19,6 +19,7 @@ package com.wire.kalium.logic.data.conversation import co.touchlab.stately.collections.ConcurrentMutableMap import com.wire.kalium.logic.data.properties.UserPropertyRepository +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.framework.TestConversation import com.wire.kalium.logic.test_util.TestKaliumDispatcher import io.mockative.Mock @@ -30,7 +31,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class TypingIndicatorRepositoryTest { +class TypingIndicatorIncomingRepositoryTest { @Test fun givenUsersInOneConversation_whenTheyAreTyping_thenAddItToTheListOfUsersTypingInConversation() = @@ -44,7 +45,7 @@ class TypingIndicatorRepositoryTest { assertEquals( setOf(expectedUserTypingOne, expectedUserTypingTwo), - typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it.userId }?.toSet() + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it }?.toSet() ) verify(arrangement.userPropertyRepository) .suspendFunction(arrangement.userPropertyRepository::getTypingIndicatorStatus) @@ -63,7 +64,7 @@ class TypingIndicatorRepositoryTest { assertEquals( expectedUserTyping, - typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it.userId }?.toSet() + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it }?.toSet() ) verify(arrangement.userPropertyRepository) .suspendFunction(arrangement.userPropertyRepository::getTypingIndicatorStatus) @@ -79,17 +80,34 @@ class TypingIndicatorRepositoryTest { assertEquals( setOf(expectedUserTypingOne), - typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it.userId }?.toSet() + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it }?.toSet() ) assertEquals( setOf(expectedUserTypingTwo), - typingIndicatorRepository.observeUsersTyping(conversationTwo).firstOrNull()?.map { it.userId }?.toSet() + typingIndicatorRepository.observeUsersTyping(conversationTwo).firstOrNull()?.map { it }?.toSet() ) verify(arrangement.userPropertyRepository) .suspendFunction(arrangement.userPropertyRepository::getTypingIndicatorStatus) .wasInvoked() } + @Test + fun givenUsersTypingInAConversation_whenClearExpiredItsCalled_thenShouldNotBePresentAnyInCached() = + runTest(TestKaliumDispatcher.default) { + val expectedUserTyping = setOf() + val (_, typingIndicatorRepository) = Arrangement().withTypingIndicatorStatus().arrange() + + typingIndicatorRepository.addTypingUserInConversation(conversationOne, TestConversation.USER_1) + typingIndicatorRepository.addTypingUserInConversation(conversationOne, TestConversation.USER_2) + + typingIndicatorRepository.clearExpiredTypingIndicators() + + assertEquals( + expectedUserTyping, + typingIndicatorRepository.observeUsersTyping(conversationOne).firstOrNull()?.map { it }?.toSet() + ) + } + private class Arrangement { @Mock val userPropertyRepository: UserPropertyRepository = mock(UserPropertyRepository::class) @@ -101,7 +119,7 @@ class TypingIndicatorRepositoryTest { .thenReturn(enabled) } - fun arrange() = this to TypingIndicatorRepositoryImpl( + fun arrange() = this to TypingIndicatorIncomingRepositoryImpl( userTypingCache = ConcurrentMutableMap(), userPropertyRepository = userPropertyRepository ) diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorOutgoingRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorOutgoingRepositoryTest.kt new file mode 100644 index 00000000000..f4ec25590a4 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/TypingIndicatorOutgoingRepositoryTest.kt @@ -0,0 +1,130 @@ +/* + * 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 com.wire.kalium.logic.data.properties.UserPropertyRepository +import com.wire.kalium.logic.framework.TestConversation +import com.wire.kalium.logic.test_util.TestKaliumDispatcher +import io.mockative.Mock +import io.mockative.any +import io.mockative.given +import io.mockative.mock +import io.mockative.verify +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class TypingIndicatorOutgoingRepositoryTest { + + @Test + fun givenAStartedTypingEvent_whenUserConfigNotEnabled_thenShouldNotSendAnyIndication() = + runTest(TestKaliumDispatcher.default) { + val (arrangement, typingIndicatorRepository) = Arrangement() + .withTypingIndicatorStatus(false) + .withSenderHandlerCall() + .arrange() + + typingIndicatorRepository.sendTypingIndicatorStatus(conversationOne, Conversation.TypingIndicatorMode.STARTED) + + verify(arrangement.userPropertyRepository) + .suspendFunction(arrangement.userPropertyRepository::getTypingIndicatorStatus) + .wasInvoked() + + verify(arrangement.typingIndicatorSenderHandler) + .function(arrangement.typingIndicatorSenderHandler::sendStartedAndEnqueueStoppingEvent) + .with(any()) + .wasNotInvoked() + } + + @Test + fun givenAStartedTypingEvent_whenUserConfigIsEnabled_thenShouldSendAnyIndication() = + runTest(TestKaliumDispatcher.default) { + val (arrangement, typingIndicatorRepository) = Arrangement() + .withTypingIndicatorStatus(true) + .withSenderHandlerCall() + .arrange() + + typingIndicatorRepository.sendTypingIndicatorStatus(conversationOne, Conversation.TypingIndicatorMode.STARTED) + + verify(arrangement.userPropertyRepository) + .suspendFunction(arrangement.userPropertyRepository::getTypingIndicatorStatus) + .wasInvoked() + + verify(arrangement.typingIndicatorSenderHandler) + .function(arrangement.typingIndicatorSenderHandler::sendStartedAndEnqueueStoppingEvent) + .with(any()) + .wasInvoked() + } + + @Test + fun givenStoppedTypingEvent_whenCalled_thenShouldDelegateCallToHandler() = + runTest(TestKaliumDispatcher.default) { + val (arrangement, typingIndicatorRepository) = Arrangement() + .withTypingIndicatorStatus(true) + .withSenderHandlerStoppedCall() + .arrange() + + typingIndicatorRepository.sendTypingIndicatorStatus(conversationOne, Conversation.TypingIndicatorMode.STOPPED) + + verify(arrangement.userPropertyRepository) + .suspendFunction(arrangement.userPropertyRepository::getTypingIndicatorStatus) + .wasInvoked() + + verify(arrangement.typingIndicatorSenderHandler) + .function(arrangement.typingIndicatorSenderHandler::sendStoppingEvent) + .with(any()) + .wasInvoked() + } + + private class Arrangement { + @Mock + val userPropertyRepository: UserPropertyRepository = mock(UserPropertyRepository::class) + + @Mock + val typingIndicatorSenderHandler: TypingIndicatorSenderHandler = mock(TypingIndicatorSenderHandler::class) + + fun withTypingIndicatorStatus(enabled: Boolean = true) = apply { + given(userPropertyRepository) + .suspendFunction(userPropertyRepository::getTypingIndicatorStatus) + .whenInvoked() + .thenReturn(enabled) + } + + fun withSenderHandlerStoppedCall() = apply { + given(typingIndicatorSenderHandler) + .function(typingIndicatorSenderHandler::sendStoppingEvent) + .whenInvokedWith(any()) + .thenReturn(Unit) + } + + fun withSenderHandlerCall() = apply { + given(typingIndicatorSenderHandler) + .function(typingIndicatorSenderHandler::sendStartedAndEnqueueStoppingEvent) + .whenInvokedWith(any()) + .thenReturn(Unit) + } + + fun arrange() = this to TypingIndicatorOutgoingRepositoryImpl( + userPropertyRepository = userPropertyRepository, + typingIndicatorSenderHandler = typingIndicatorSenderHandler + ) + } + + private companion object { + val conversationOne = TestConversation.ID + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/ClearUsersTypingEventsUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/ClearUsersTypingEventsUseCaseTest.kt new file mode 100644 index 00000000000..835001dc473 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/ClearUsersTypingEventsUseCaseTest.kt @@ -0,0 +1,50 @@ +/* + * 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.TypingIndicatorIncomingRepository +import io.mockative.Mock +import io.mockative.mock +import io.mockative.once +import io.mockative.verify +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class ClearUsersTypingEventsUseCaseTest { + + @Test + fun givenClearingTypingIndicatorSucceeds_whenInvoking_thenShouldDelegateToRepositoryCall() = runTest { + val (arrangement, useCase) = Arrangement().arrange() + + useCase() + + verify(arrangement.typingIndicatorIncomingRepository) + .suspendFunction(arrangement.typingIndicatorIncomingRepository::clearExpiredTypingIndicators) + .wasInvoked(once) + } + + private class Arrangement { + + @Mock + val typingIndicatorIncomingRepository: TypingIndicatorIncomingRepository = mock(TypingIndicatorIncomingRepository::class) + + fun arrange() = this to ClearUsersTypingEventsUseCaseImpl( + typingIndicatorIncomingRepository = typingIndicatorIncomingRepository + ) + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/SendTypingEventUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/SendTypingEventUseCaseTest.kt new file mode 100644 index 00000000000..9ad7c63342b --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/SendTypingEventUseCaseTest.kt @@ -0,0 +1,87 @@ +/* + * 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.CoreFailure +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.TypingIndicatorOutgoingRepository +import com.wire.kalium.logic.framework.TestConversation +import com.wire.kalium.logic.functional.Either +import io.mockative.Mock +import io.mockative.any +import io.mockative.eq +import io.mockative.given +import io.mockative.mock +import io.mockative.verify +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SendTypingEventUseCaseTest { + + @Test + fun givenATypingEvent_whenCallingSendSucceed_thenReturnSuccess() = runTest { + val (arrangement, useCase) = Arrangement() + .withTypingIndicatorStatusAndResult(Conversation.TypingIndicatorMode.STOPPED) + .arrange() + + useCase(TestConversation.ID, Conversation.TypingIndicatorMode.STOPPED) + + verify(arrangement.typingIndicatorRepository) + .suspendFunction(arrangement.typingIndicatorRepository::sendTypingIndicatorStatus) + .with(eq(TestConversation.ID), eq(Conversation.TypingIndicatorMode.STOPPED)) + .wasInvoked() + } + + @Test + fun givenATypingEvent_whenCallingSendFails_thenReturnIgnoringFailure() = runTest { + val (arrangement, useCase) = Arrangement() + .withTypingIndicatorStatusAndResult( + Conversation.TypingIndicatorMode.STARTED, + Either.Left(CoreFailure.Unknown(RuntimeException("Some error"))) + ) + .arrange() + + val result = useCase(TestConversation.ID, Conversation.TypingIndicatorMode.STARTED) + + verify(arrangement.typingIndicatorRepository) + .suspendFunction(arrangement.typingIndicatorRepository::sendTypingIndicatorStatus) + .with(eq(TestConversation.ID), eq(Conversation.TypingIndicatorMode.STARTED)) + .wasInvoked() + assertEquals(Unit, result) + } + + private class Arrangement { + @Mock + val typingIndicatorRepository: TypingIndicatorOutgoingRepository = mock(TypingIndicatorOutgoingRepository::class) + + fun withTypingIndicatorStatusAndResult( + typingMode: Conversation.TypingIndicatorMode, + result: Either = Either.Right(Unit) + ) = apply { + given(typingIndicatorRepository) + .suspendFunction(typingIndicatorRepository::sendTypingIndicatorStatus) + .whenInvokedWith(any(), eq(typingMode)) + .thenReturn(result) + } + + fun arrange() = this to SendTypingEventUseCaseImpl( + typingIndicatorRepository + ) + } +} 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 index 4cb6a1f281e..4cbe5429581 100644 --- 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 @@ -18,8 +18,7 @@ package com.wire.kalium.logic.sync.receiver.handler import com.wire.kalium.logic.data.conversation.Conversation -import com.wire.kalium.logic.data.conversation.ExpiringUserTyping -import com.wire.kalium.logic.data.conversation.TypingIndicatorRepository +import com.wire.kalium.logic.data.conversation.TypingIndicatorIncomingRepository import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.framework.TestConversation import com.wire.kalium.logic.framework.TestEvent @@ -33,7 +32,6 @@ import io.mockative.once import io.mockative.verify import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Clock import kotlin.test.Test class TypingIndicatorHandlerTest { @@ -47,8 +45,8 @@ class TypingIndicatorHandlerTest { val result = handler.handle(TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STARTED)) result.shouldSucceed() - verify(arrangement.typingIndicatorRepository) - .function(arrangement.typingIndicatorRepository::addTypingUserInConversation) + verify(arrangement.typingIndicatorIncomingRepository) + .function(arrangement.typingIndicatorIncomingRepository::addTypingUserInConversation) .with(eq(TestConversation.ID), eq(TestUser.SELF.id)) .wasNotInvoked() } @@ -62,8 +60,8 @@ class TypingIndicatorHandlerTest { val result = handler.handle(TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STARTED)) result.shouldSucceed() - verify(arrangement.typingIndicatorRepository) - .function(arrangement.typingIndicatorRepository::addTypingUserInConversation) + verify(arrangement.typingIndicatorIncomingRepository) + .function(arrangement.typingIndicatorIncomingRepository::addTypingUserInConversation) .with(eq(TestConversation.ID), eq(TestUser.OTHER_USER_ID)) .wasInvoked(once) } @@ -77,8 +75,8 @@ class TypingIndicatorHandlerTest { val result = handler.handle(TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STOPPED)) result.shouldSucceed() - verify(arrangement.typingIndicatorRepository) - .function(arrangement.typingIndicatorRepository::removeTypingUserInConversation) + verify(arrangement.typingIndicatorIncomingRepository) + .function(arrangement.typingIndicatorIncomingRepository::removeTypingUserInConversation) .with(eq(TestConversation.ID), eq(TestUser.OTHER_USER_ID)) .wasInvoked(once) } @@ -92,24 +90,24 @@ class TypingIndicatorHandlerTest { val result = handler.handle(TestEvent.typingIndicator(Conversation.TypingIndicatorMode.STOPPED)) result.shouldSucceed() - verify(arrangement.typingIndicatorRepository) - .function(arrangement.typingIndicatorRepository::removeTypingUserInConversation) + verify(arrangement.typingIndicatorIncomingRepository) + .function(arrangement.typingIndicatorIncomingRepository::removeTypingUserInConversation) .with(eq(TestConversation.ID), eq(TestUser.SELF.id)) .wasNotInvoked() } private class Arrangement { @Mock - val typingIndicatorRepository: TypingIndicatorRepository = mock(TypingIndicatorRepository::class) + val typingIndicatorIncomingRepository: TypingIndicatorIncomingRepository = mock(TypingIndicatorIncomingRepository::class) fun withTypingIndicatorObserve(usersId: Set) = apply { - given(typingIndicatorRepository) - .suspendFunction(typingIndicatorRepository::observeUsersTyping) + given(typingIndicatorIncomingRepository) + .suspendFunction(typingIndicatorIncomingRepository::observeUsersTyping) .whenInvokedWith(eq(TestConversation.ID)) - .thenReturn(flowOf(usersId.map { ExpiringUserTyping(it, Clock.System.now()) }.toSet())) + .thenReturn(flowOf(usersId)) } - fun arrange() = this to TypingIndicatorHandlerImpl(TestUser.SELF.id, typingIndicatorRepository) + fun arrange() = this to TypingIndicatorHandlerImpl(TestUser.SELF.id, typingIndicatorIncomingRepository) } } diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/ConversationApi.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/ConversationApi.kt index 27092e6727d..6058888dd8a 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/ConversationApi.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/conversation/ConversationApi.kt @@ -147,4 +147,9 @@ interface ConversationApi { conversationId: ConversationId, messageTimer: Long? ): NetworkResponse + + suspend fun sendTypingIndicatorNotification( + conversationId: ConversationId, + typingIndicatorMode: TypingIndicatorStatusDTO + ): NetworkResponse } diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/ConversationApiV0.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/ConversationApiV0.kt index bdf36b87299..49bba38c383 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/ConversationApiV0.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/ConversationApiV0.kt @@ -34,6 +34,7 @@ import com.wire.kalium.network.api.base.authenticated.conversation.CreateConvers import com.wire.kalium.network.api.base.authenticated.conversation.MemberUpdateDTO import com.wire.kalium.network.api.base.authenticated.conversation.SubconversationDeleteRequest import com.wire.kalium.network.api.base.authenticated.conversation.SubconversationResponse +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO import com.wire.kalium.network.api.base.authenticated.conversation.UpdateConversationAccessRequest import com.wire.kalium.network.api.base.authenticated.conversation.UpdateConversationAccessResponse import com.wire.kalium.network.api.base.authenticated.conversation.UpdateConversationReceiptModeResponse @@ -384,6 +385,16 @@ internal open class ConversationApiV0 internal constructor( } } + override suspend fun sendTypingIndicatorNotification( + conversationId: ConversationId, + typingIndicatorMode: TypingIndicatorStatusDTO + ): NetworkResponse = + wrapKaliumResponse { + httpClient.post("$PATH_CONVERSATIONS/${conversationId.value}/$PATH_TYPING_NOTIFICATION") { + setBody(typingIndicatorMode) + } + } + protected companion object { const val PATH_CONVERSATIONS = "conversations" const val PATH_SELF = "self" @@ -404,6 +415,7 @@ internal open class ConversationApiV0 internal constructor( const val QUERY_KEY_START = "start" const val QUERY_KEY_SIZE = "size" const val QUERY_KEY_IDS = "qualified_ids" + const val PATH_TYPING_NOTIFICATION = "typing" const val MAX_CONVERSATION_DETAILS_COUNT = 1000 } diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/ConversationApiV4.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/ConversationApiV4.kt index a1f7ec00002..7154b65db19 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/ConversationApiV4.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/ConversationApiV4.kt @@ -23,6 +23,7 @@ import com.wire.kalium.network.api.base.authenticated.conversation.AddConversati import com.wire.kalium.network.api.base.authenticated.conversation.ConversationMemberAddedResponse import com.wire.kalium.network.api.base.authenticated.conversation.ConversationResponseV3 import com.wire.kalium.network.api.base.authenticated.conversation.CreateConversationRequest +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO import com.wire.kalium.network.api.base.authenticated.conversation.model.ConversationCodeInfo import com.wire.kalium.network.api.base.authenticated.notification.EventContentDTO import com.wire.kalium.network.api.base.model.ApiModelMapper @@ -107,6 +108,16 @@ internal open class ConversationApiV4 internal constructor( } } + override suspend fun sendTypingIndicatorNotification( + conversationId: ConversationId, + typingIndicatorMode: TypingIndicatorStatusDTO + ): NetworkResponse = + wrapKaliumResponse { + httpClient.post("$PATH_CONVERSATIONS/${conversationId.domain}/${conversationId.value}/$PATH_TYPING_NOTIFICATION") { + setBody(typingIndicatorMode) + } + } + companion object { const val PATH_GROUP_INFO = "groupinfo" const val PATH_SUBCONVERSATIONS = "subconversations" diff --git a/network/src/commonTest/kotlin/com/wire/kalium/api/v0/conversation/ConversationApiV0Test.kt b/network/src/commonTest/kotlin/com/wire/kalium/api/v0/conversation/ConversationApiV0Test.kt index e7ed016c22c..b1c0d474712 100644 --- a/network/src/commonTest/kotlin/com/wire/kalium/api/v0/conversation/ConversationApiV0Test.kt +++ b/network/src/commonTest/kotlin/com/wire/kalium/api/v0/conversation/ConversationApiV0Test.kt @@ -27,12 +27,15 @@ import com.wire.kalium.model.conversation.ConversationListIdsResponseJson import com.wire.kalium.model.conversation.ConversationResponseJson import com.wire.kalium.model.conversation.CreateConversationRequestJson import com.wire.kalium.model.conversation.MemberUpdateRequestJson +import com.wire.kalium.model.conversation.SendTypingStatusNotificationRequestJson import com.wire.kalium.model.conversation.UpdateConversationAccessRequestJson import com.wire.kalium.network.api.base.authenticated.conversation.AddConversationMembersRequest import com.wire.kalium.network.api.base.authenticated.conversation.AddServiceRequest import com.wire.kalium.network.api.base.authenticated.conversation.ConversationApi import com.wire.kalium.network.api.base.authenticated.conversation.ConversationMemberAddedResponse import com.wire.kalium.network.api.base.authenticated.conversation.ReceiptMode +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatus +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO import com.wire.kalium.network.api.base.authenticated.conversation.UpdateConversationAccessRequest import com.wire.kalium.network.api.base.authenticated.conversation.UpdateConversationAccessResponse import com.wire.kalium.network.api.base.authenticated.conversation.model.ConversationMemberRoleDTO @@ -415,6 +418,30 @@ internal class ConversationApiV0Test : ApiTest() { assertIs>(response) } + @Test + fun givenTypingNotificationRequest_whenSendingStatus_thenTheRequestShouldBeConfiguredCorrectly() = runTest { + // given + val conversationId = ConversationId("conversationId", "conversationDomain") + val request = TypingIndicatorStatusDTO(TypingIndicatorStatus.STOPPED) + + val networkClient = mockAuthenticatedNetworkClient( + ByteArray(0), + statusCode = HttpStatusCode.OK, + assertion = { + assertPost() + assertPathEqual("${PATH_CONVERSATIONS}/${conversationId.value}/${PATH_TYPING_NOTIFICATION}") + assertJsonBodyContent(SendTypingStatusNotificationRequestJson.createValid(TypingIndicatorStatus.STOPPED).rawJson) + } + ) + val conversationApi = ConversationApiV0(networkClient) + + // when + val response = conversationApi.sendTypingIndicatorNotification(conversationId, request) + + // then + assertIs>(response) + } + private companion object { const val PATH_CONVERSATIONS = "/conversations" const val PATH_CONVERSATIONS_LIST_V2 = "/conversations/list/v2" @@ -425,6 +452,7 @@ internal class ConversationApiV0Test : ApiTest() { const val PATH_JOIN = "join" const val PATH_RECEIPT_MODE = "receipt-mode" const val PATH_CODE = "code" + const val PATH_TYPING_NOTIFICATION = "typing" val CREATE_CONVERSATION_RESPONSE = ConversationResponseJson.v0.rawJson val CREATE_CONVERSATION_REQUEST = CreateConversationRequestJson.v0 val CREATE_CONVERSATION_IDS_REQUEST = ConversationListIdsResponseJson.validRequestIds diff --git a/network/src/commonTest/kotlin/com/wire/kalium/api/v4/ConversationApiV4Test.kt b/network/src/commonTest/kotlin/com/wire/kalium/api/v4/ConversationApiV4Test.kt index a843d43fdb5..e4176395c38 100644 --- a/network/src/commonTest/kotlin/com/wire/kalium/api/v4/ConversationApiV4Test.kt +++ b/network/src/commonTest/kotlin/com/wire/kalium/api/v4/ConversationApiV4Test.kt @@ -22,12 +22,16 @@ import com.wire.kalium.api.ApiTest import com.wire.kalium.api.json.model.ErrorResponseJson import com.wire.kalium.model.EventContentDTOJson import com.wire.kalium.model.conversation.CreateConversationRequestJson +import com.wire.kalium.model.conversation.SendTypingStatusNotificationRequestJson import com.wire.kalium.network.api.base.authenticated.conversation.AddConversationMembersRequest +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatus +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO import com.wire.kalium.network.api.base.model.ConversationId import com.wire.kalium.network.api.base.model.FederationConflictResponse import com.wire.kalium.network.api.base.model.UserId import com.wire.kalium.network.api.v4.authenticated.ConversationApiV4 import com.wire.kalium.network.exceptions.KaliumException +import com.wire.kalium.network.utils.NetworkResponse import com.wire.kalium.network.utils.UnreachableRemoteBackends import com.wire.kalium.network.utils.isSuccessful import io.ktor.http.HttpStatusCode @@ -35,6 +39,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue internal class ConversationApiV4Test : ApiTest() { @@ -122,9 +127,30 @@ internal class ConversationApiV4Test : ApiTest() { ) } + @Test + fun givenTypingNotificationRequest_whenSendingStatus_thenTheRequestShouldBeConfiguredCorrectly() = runTest { + val conversationId = ConversationId("conversationId", "conversationDomain") + val request = TypingIndicatorStatusDTO(TypingIndicatorStatus.STARTED) + + val networkClient = mockAuthenticatedNetworkClient( + ByteArray(0), + statusCode = HttpStatusCode.OK, + assertion = { + assertPost() + assertPathEqual("${PATH_CONVERSATIONS}/${conversationId.domain}/${conversationId.value}/${PATH_TYPING_NOTIFICATION}") + assertJsonBodyContent(SendTypingStatusNotificationRequestJson.createValid(TypingIndicatorStatus.STARTED).rawJson) + } + ) + val conversationApi = ConversationApiV4(networkClient) + conversationApi.sendTypingIndicatorNotification(conversationId, request).also { + assertIs>(it) + } + } + private companion object { const val PATH_CONVERSATIONS = "/conversations" const val PATH_MEMBERS = "members" + const val PATH_TYPING_NOTIFICATION = "typing" val CREATE_CONVERSATION_REQUEST = CreateConversationRequestJson.v3 } } diff --git a/network/src/commonTest/kotlin/com/wire/kalium/model/conversation/SendTypingStatusNotificationRequestJson.kt b/network/src/commonTest/kotlin/com/wire/kalium/model/conversation/SendTypingStatusNotificationRequestJson.kt new file mode 100644 index 00000000000..78d6d7db5b8 --- /dev/null +++ b/network/src/commonTest/kotlin/com/wire/kalium/model/conversation/SendTypingStatusNotificationRequestJson.kt @@ -0,0 +1,37 @@ +/* + * 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.model.conversation + +import com.wire.kalium.api.json.ValidJsonProvider +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatus +import com.wire.kalium.network.api.base.authenticated.conversation.TypingIndicatorStatusDTO + +object SendTypingStatusNotificationRequestJson { + + private val jsonProvider = { serializable: TypingIndicatorStatusDTO -> + """ + |{ + | "status": "${serializable.status.value}" + |} + """.trimMargin() + } + + fun createValid(typingIndicatorStatus: TypingIndicatorStatus) = + ValidJsonProvider(TypingIndicatorStatusDTO(typingIndicatorStatus), jsonProvider) + +}