From 6128fada10f8fe33848f30ae322416fdf1a5bdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojtek=20Kalici=C5=84ski?= <146713236+wkal-pubnub@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:07:53 +0100 Subject: [PATCH] Client side mute list (#160) * Adds the ability to mute and unmute users through `Chat.mutedUsers.muteUser()` / `unmuteUser()` * Adds the option to automatically sync the mute list using AppContext by enabling `ChatConfiguration.syncMutedUsers` * Unrelated change: missing function to parse quoted message text into parts * Unrelated change: technical channels and users will have type set to "pn.prv" --- api/pubnub-chat.api | 1 + js-chat/package.json | 2 +- js-chat/tests/mutelist.test.ts | 55 +++++ pubnub-chat-api/api/pubnub-chat-api.api | 13 +- .../kotlin/com/pubnub/chat/Channel.kt | 3 + .../commonMain/kotlin/com/pubnub/chat/Chat.kt | 19 ++ .../pubnub/chat/config/ChatConfiguration.kt | 22 ++ .../pubnub/chat/mutelist/MutedUsersManager.kt | 37 ++++ .../com/pubnub/chat/types/ChannelType.kt | 11 +- pubnub-chat-impl/config/ktlint/baseline.xml | 2 +- .../com/pubnub/chat/internal/ChatImpl.kt | 48 +++-- .../com/pubnub/chat/internal/Constants.kt | 4 + .../com/pubnub/chat/internal/EventImpl.kt | 4 + .../chat/internal/channel/BaseChannel.kt | 11 +- .../mutelist/MutedUsersManagerImpl.kt | 113 ++++++++++ .../com/pubnub/chat/internal/util/Utils.kt | 29 ++- .../integration/BaseChatIntegrationTest.kt | 118 +++++++---- .../pubnub/integration/ChatIntegrationTest.kt | 42 +++- .../integration/MutedUsersIntegrationTest.kt | 196 ++++++++++++++++++ .../pubnub/integration/UserIntegrationTest.kt | 8 +- .../kotlin/com/pubnub/kmp/ChannelTest.kt | 5 +- .../kotlin/com/pubnub/kmp/utils/FakeChat.kt | 4 + pubnub-chat-impl/src/jsMain/kotlin/ChatJs.kt | 2 + .../src/jsMain/kotlin/MutedUsersManagerJs.kt | 16 ++ .../src/jsMain/kotlin/QuotedMessageJs.kt | 14 ++ .../src/jsMain/kotlin/converters.kt | 15 +- pubnub-chat-impl/src/jsMain/kotlin/types.kt | 7 +- .../com.pubnub.test/BaseIntegrationTest.kt | 7 + .../kotlin/com/pubnub/chat/mediators.kt | 5 + src/jsMain/resources/index.d.ts | 18 +- .../compubnub/chat/ChatIntegrationTest.kt | 18 +- 31 files changed, 725 insertions(+), 124 deletions(-) create mode 100644 js-chat/tests/mutelist.test.ts create mode 100644 pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/mutelist/MutedUsersManager.kt create mode 100644 pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/mutelist/MutedUsersManagerImpl.kt create mode 100644 pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MutedUsersIntegrationTest.kt create mode 100644 pubnub-chat-impl/src/jsMain/kotlin/MutedUsersManagerJs.kt create mode 100644 pubnub-chat-impl/src/jsMain/kotlin/QuotedMessageJs.kt diff --git a/api/pubnub-chat.api b/api/pubnub-chat.api index eb84f929..8e3d503c 100644 --- a/api/pubnub-chat.api +++ b/api/pubnub-chat.api @@ -2,6 +2,7 @@ public final class com/pubnub/chat/MediatorsKt { public static final fun createMessageDraft (Lcom/pubnub/chat/Channel;Lcom/pubnub/chat/MessageDraft$UserSuggestionSource;ZII)Lcom/pubnub/chat/MessageDraft; public static synthetic fun createMessageDraft$default (Lcom/pubnub/chat/Channel;Lcom/pubnub/chat/MessageDraft$UserSuggestionSource;ZIIILjava/lang/Object;)Lcom/pubnub/chat/MessageDraft; public static final fun getMessageElements (Lcom/pubnub/chat/Message;)Ljava/util/List; + public static final fun getMessageElements (Lcom/pubnub/chat/types/QuotedMessage;)Ljava/util/List; public static final fun streamUpdatesOn (Lcom/pubnub/chat/Channel$Companion;Ljava/util/Collection;Lkotlin/jvm/functions/Function1;)Ljava/lang/AutoCloseable; public static final fun streamUpdatesOn (Lcom/pubnub/chat/Membership$Companion;Ljava/util/Collection;Lkotlin/jvm/functions/Function1;)Ljava/lang/AutoCloseable; public static final fun streamUpdatesOn (Lcom/pubnub/chat/Message$Companion;Ljava/util/Collection;Lkotlin/jvm/functions/Function1;)Ljava/lang/AutoCloseable; diff --git a/js-chat/package.json b/js-chat/package.json index e792e765..0bda8c34 100644 --- a/js-chat/package.json +++ b/js-chat/package.json @@ -44,7 +44,7 @@ "version": "0.10.0", "name": "@pubnub/chat", "dependencies": { - "pubnub": "8.3.1", + "pubnub": "8.4.1", "format-util": "^1.0.5" } } \ No newline at end of file diff --git a/js-chat/tests/mutelist.test.ts b/js-chat/tests/mutelist.test.ts new file mode 100644 index 00000000..210d9463 --- /dev/null +++ b/js-chat/tests/mutelist.test.ts @@ -0,0 +1,55 @@ +import { + Channel, + Message, + Chat, + MessageDraft, + INTERNAL_MODERATION_PREFIX, + Membership, +} from "../dist-test" +import { + sleep, + extractMentionedUserIds, + createRandomUser, + createRandomChannel, + createChatInstance, + sendMessageAndWaitForHistory, + makeid, +} from "./utils" + +import { jest } from "@jest/globals" + +describe("Mute list test", () => { + jest.retryTimes(3) + + let chat: Chat + let channel: Channel + let messageDraft: MessageDraft + + beforeAll(async () => { + chat = await createChatInstance() + }) + + beforeEach(async () => { + channel = await createRandomChannel() + messageDraft = channel.createMessageDraft() + }) + + afterEach(async () => { + await channel.delete() + jest.clearAllMocks() + }) + + test("should add user to mute set", async () => { + await chat.mutedUsersManager.muteUser("abc") + expect(chat.mutedUsersManager.mutedUsers[0]).toBe("abc") + }) + + test("should remove user from mute set", async () => { + await chat.mutedUsersManager.muteUser("abc") + await chat.mutedUsersManager.muteUser("def") + await chat.mutedUsersManager.unmuteUser("abc") + expect(chat.mutedUsersManager.mutedUsers[0]).toBe("def") + expect(chat.mutedUsersManager.mutedUsers.length).toBe(1) + }) + +}) diff --git a/pubnub-chat-api/api/pubnub-chat-api.api b/pubnub-chat-api/api/pubnub-chat-api.api index 409db660..39624b73 100644 --- a/pubnub-chat-api/api/pubnub-chat-api.api +++ b/pubnub-chat-api/api/pubnub-chat-api.api @@ -86,6 +86,7 @@ public abstract interface class com/pubnub/chat/Chat { public static synthetic fun getCurrentUserMentions$default (Lcom/pubnub/chat/Chat;Ljava/lang/Long;Ljava/lang/Long;IILjava/lang/Object;)Lcom/pubnub/kmp/PNFuture; public abstract fun getEventsHistory (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;I)Lcom/pubnub/kmp/PNFuture; public static synthetic fun getEventsHistory$default (Lcom/pubnub/chat/Chat;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;IILjava/lang/Object;)Lcom/pubnub/kmp/PNFuture; + public abstract fun getMutedUsersManager ()Lcom/pubnub/chat/mutelist/MutedUsersManager; public abstract fun getPubNub ()Lcom/pubnub/api/PubNub; public abstract fun getPushChannels ()Lcom/pubnub/kmp/PNFuture; public abstract fun getUnreadMessagesCounts (Ljava/lang/Integer;Lcom/pubnub/api/models/consumer/objects/PNPage;Ljava/lang/String;Ljava/util/Collection;)Lcom/pubnub/kmp/PNFuture; @@ -361,12 +362,13 @@ public abstract interface class com/pubnub/chat/config/ChatConfiguration { public abstract fun getRateLimitPerChannel ()Ljava/util/Map; public abstract fun getStoreUserActivityInterval-UwyO8pc ()J public abstract fun getStoreUserActivityTimestamps ()Z + public abstract fun getSyncMutedUsers ()Z public abstract fun getTypingTimeout-UwyO8pc ()J } public final class com/pubnub/chat/config/ChatConfigurationKt { - public static final fun ChatConfiguration-QkTvx9o (Lcom/pubnub/chat/config/LogLevel;JJZLcom/pubnub/chat/config/PushNotificationsConfig;ILjava/util/Map;Lcom/pubnub/chat/config/CustomPayloads;)Lcom/pubnub/chat/config/ChatConfiguration; - public static synthetic fun ChatConfiguration-QkTvx9o$default (Lcom/pubnub/chat/config/LogLevel;JJZLcom/pubnub/chat/config/PushNotificationsConfig;ILjava/util/Map;Lcom/pubnub/chat/config/CustomPayloads;ILjava/lang/Object;)Lcom/pubnub/chat/config/ChatConfiguration; + public static final fun ChatConfiguration-ZH2SSTU (Lcom/pubnub/chat/config/LogLevel;JJZLcom/pubnub/chat/config/PushNotificationsConfig;ILjava/util/Map;Lcom/pubnub/chat/config/CustomPayloads;Z)Lcom/pubnub/chat/config/ChatConfiguration; + public static synthetic fun ChatConfiguration-ZH2SSTU$default (Lcom/pubnub/chat/config/LogLevel;JJZLcom/pubnub/chat/config/PushNotificationsConfig;ILjava/util/Map;Lcom/pubnub/chat/config/CustomPayloads;ZILjava/lang/Object;)Lcom/pubnub/chat/config/ChatConfiguration; public static final fun RateLimitPerChannel-InTURus (JJJJ)Ljava/util/Map; public static synthetic fun RateLimitPerChannel-InTURus$default (JJJJILjava/lang/Object;)Ljava/util/Map; } @@ -438,6 +440,12 @@ public final class com/pubnub/chat/message/MarkAllMessageAsReadResponse { public final fun getTotal ()I } +public abstract interface class com/pubnub/chat/mutelist/MutedUsersManager { + public abstract fun getMutedUsers ()Ljava/util/Set; + public abstract fun muteUser (Ljava/lang/String;)Lcom/pubnub/kmp/PNFuture; + public abstract fun unmuteUser (Ljava/lang/String;)Lcom/pubnub/kmp/PNFuture; +} + public final class com/pubnub/chat/restrictions/GetRestrictionsResponse { public fun (Ljava/util/List;Lcom/pubnub/api/models/consumer/objects/PNPage$PNNext;Lcom/pubnub/api/models/consumer/objects/PNPage$PNPrev;II)V public final fun getNext ()Lcom/pubnub/api/models/consumer/objects/PNPage$PNNext; @@ -484,6 +492,7 @@ public final class com/pubnub/chat/types/ChannelType : java/lang/Enum { public static final field DIRECT Lcom/pubnub/chat/types/ChannelType; public static final field GROUP Lcom/pubnub/chat/types/ChannelType; public static final field PUBLIC Lcom/pubnub/chat/types/ChannelType; + public static final field PUBNUB_PRIVATE Lcom/pubnub/chat/types/ChannelType; public static final field UNKNOWN Lcom/pubnub/chat/types/ChannelType; public static fun getEntries ()Lkotlin/enums/EnumEntries; public final fun getStringValue ()Ljava/lang/String; diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Channel.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Channel.kt index 3514622e..60b85b62 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Channel.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Channel.kt @@ -401,6 +401,9 @@ interface Channel { /** * Allows to mute/ban a specific user on a channel or unmute/unban them. * + * Please note that this is a server-side moderation mechanism, as opposed to [Chat.mutedUsersManager] (which is local to + * a client). + * * @param user to be muted or banned. * @param ban represents the user's moderation restrictions. Set to true to ban the user from the channel or to false to unban them. * @param mute represents the user's moderation restrictions. Set to true to mute the user on the channel or to false to unmute them. diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt index 81a6ac48..abd09690 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt @@ -11,6 +11,7 @@ import com.pubnub.api.models.consumer.push.PNPushRemoveChannelResult import com.pubnub.chat.config.ChatConfiguration import com.pubnub.chat.message.GetUnreadMessagesCounts import com.pubnub.chat.message.MarkAllMessageAsReadResponse +import com.pubnub.chat.mutelist.MutedUsersManager import com.pubnub.chat.restrictions.Restriction import com.pubnub.chat.types.ChannelType import com.pubnub.chat.types.CreateDirectConversationResult @@ -52,6 +53,21 @@ interface Chat { */ val currentUser: User + /** + * An object for manipulating the list of muted users. + * + * The list is local to this instance of Chat (it is not persisted anywhere) unless + * [ChatConfiguration.syncMutedUsers] is enabled, in which case it will be synced using App Context for the current + * user. + * + * Please note that this is not a server-side moderation mechanism (use [Chat.setRestrictions] for that), but rather + * a way to ignore messages from certain users on the client. + * + * @see ChatConfiguration.syncMutedUsers + * @see MutedUsersManager + */ + val mutedUsersManager: MutedUsersManager + /** * Creates a new [User] with a unique User ID. * @@ -360,6 +376,9 @@ interface Chat { /** * Allows to mute/ban a specific user on a channel or unmute/unban them. * + * Please note that this is a server-side moderation mechanism, as opposed to [Chat.mutedUsersManager] (which is local to + * a client). + * * @param restriction containing restriction details. * * @return [PNFuture] that will be completed with Unit. diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt index 11c6905c..c720933f 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt @@ -66,6 +66,26 @@ interface ChatConfiguration { * It also lets you configure your own message actions whenever a message is edited or deleted. */ val customPayloads: CustomPayloads? + + /** + * Enable automatic syncing of the [com.pubnub.chat.mutelist.MutedUsersManager] data with App Context, + * using the current `userId` as the key. + * + * Specifically, the data is saved in the `custom` object of the following User in App Context: + * + * ``` + * PN_PRIV.{userId}.mute.1 + * ``` + * + * where {userId} is the current [com.pubnub.api.v2.PNConfiguration.userId]. + * + * If using Access Manager, the access token must be configured with the appropriate rights to subscribe to that + * channel, and get, update, and delete the App Context User with that id. + * + * Due to App Context size limits, the number of muted users is limited to around 200 and will result in sync errors + * when the limit is exceeded. The list will not sync until its size is reduced. + */ + val syncMutedUsers: Boolean } fun ChatConfiguration( @@ -83,6 +103,7 @@ fun ChatConfiguration( rateLimitFactor: Int = 2, rateLimitPerChannel: Map = RateLimitPerChannel(), customPayloads: CustomPayloads? = null, + syncMutedUsers: Boolean = false, ): ChatConfiguration = object : ChatConfiguration { override val logLevel: LogLevel = logLevel override val typingTimeout: Duration = typingTimeout @@ -92,6 +113,7 @@ fun ChatConfiguration( override val rateLimitFactor: Int = rateLimitFactor override val rateLimitPerChannel: Map = rateLimitPerChannel override val customPayloads: CustomPayloads? = customPayloads + override val syncMutedUsers: Boolean = syncMutedUsers } typealias RateLimitPerChannel = Map diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/mutelist/MutedUsersManager.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/mutelist/MutedUsersManager.kt new file mode 100644 index 00000000..7f439d95 --- /dev/null +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/mutelist/MutedUsersManager.kt @@ -0,0 +1,37 @@ +package com.pubnub.chat.mutelist + +import com.pubnub.chat.config.ChatConfiguration +import com.pubnub.kmp.PNFuture + +interface MutedUsersManager { + /** + * The current set of muted users. + */ + val mutedUsers: Set + + /** + * Add a user to the list of muted users. + * + * @param userId the ID of the user to mute + * @return a PNFuture to monitor syncing data to the server. + * + * When [ChatConfiguration.syncMutedUsers] is enabled, it can fail e.g. because of network + * conditions or when number of muted users exceeds the limit. + * + * When `syncMutedUsers` is false, it always succeeds (data is not synced in that case). + */ + fun muteUser(userId: String): PNFuture + + /** + * Add a user to the list of muted users. + * + * @param userId the ID of the user to mute + * @return a PNFuture to monitor syncing data to the server. + * + * When [ChatConfiguration.syncMutedUsers] is enabled, it can fail e.g. because of network + * conditions or when number of muted users exceeds the limit. + * + * When `syncMutedUsers` is false, it always succeeds (data is not synced in that case). + */ + fun unmuteUser(userId: String): PNFuture +} diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/ChannelType.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/ChannelType.kt index e2430a60..cdd1b75d 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/ChannelType.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/ChannelType.kt @@ -7,6 +7,7 @@ private const val CHANNELTYPE_DIRECT = "direct" private const val CHANNELTYPE_GROUP = "group" private const val CHANNELTYPE_PUBLIC = "public" private const val CHANNELTYPE_UNKKNOWN = "unknown" +private const val CHANNELTYPE_PUBNUB_PRIVATE = "pn.prv" /** * Enum class representing the different types of channels that can be created. @@ -37,7 +38,15 @@ enum class ChannelType(val stringValue: String) { * An unknown channel type, used as a fallback when the type is unrecognized. */ @SerialName(CHANNELTYPE_UNKKNOWN) - UNKNOWN(CHANNELTYPE_UNKKNOWN); + UNKNOWN(CHANNELTYPE_UNKKNOWN), + + /** + * A technical channel used by chat for storing additional metadata. Not for normal use. + */ + @SerialName(CHANNELTYPE_PUBNUB_PRIVATE) + PUBNUB_PRIVATE(CHANNELTYPE_PUBNUB_PRIVATE), + + ; companion object { fun from(type: String?): ChannelType { diff --git a/pubnub-chat-impl/config/ktlint/baseline.xml b/pubnub-chat-impl/config/ktlint/baseline.xml index 43be00e4..f0015a77 100644 --- a/pubnub-chat-impl/config/ktlint/baseline.xml +++ b/pubnub-chat-impl/config/ktlint/baseline.xml @@ -1,6 +1,6 @@ - + diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt index 4cb881a7..fdd0bf50 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt @@ -75,12 +75,14 @@ import com.pubnub.chat.internal.error.PubNubErrorMessage.THREAD_FOR_THIS_MESSAGE import com.pubnub.chat.internal.error.PubNubErrorMessage.USER_ID_ALREADY_EXIST import com.pubnub.chat.internal.error.PubNubErrorMessage.USER_NOT_EXIST import com.pubnub.chat.internal.error.PubNubErrorMessage.YOU_CAN_NOT_CREATE_THREAD_ON_DELETED_MESSAGES +import com.pubnub.chat.internal.mutelist.MutedUsersManagerImpl import com.pubnub.chat.internal.serialization.PNDataEncoder import com.pubnub.chat.internal.timer.PlatformTimer import com.pubnub.chat.internal.timer.TimerManager import com.pubnub.chat.internal.timer.createTimerManager import com.pubnub.chat.internal.util.channelsUrlDecoded import com.pubnub.chat.internal.util.logErrorAndReturnException +import com.pubnub.chat.internal.util.nullOn404 import com.pubnub.chat.internal.util.pnError import com.pubnub.chat.internal.utils.cyrb53a import com.pubnub.chat.membership.MembershipsResponse @@ -130,6 +132,8 @@ class ChatImpl( UserImpl(this, pubNub.configuration.userId.value, name = pubNub.configuration.userId.value) private set + override val mutedUsersManager = MutedUsersManagerImpl(pubNub, pubNub.configuration.userId.value, config.syncMutedUsers) + private val suggestedChannelsCache: MutableMap> = mutableMapOf() private val suggestedUsersCache: MutableMap> = mutableMapOf() @@ -149,7 +153,7 @@ class ChatImpl( } fun initialize(): PNFuture { - return getUser(pubNub.configuration.userId.value).thenAsync { user -> + val userFuture = getUser(pubNub.configuration.userId.value).thenAsync { user -> user?.asFuture() ?: createUser(currentUser) }.then { user -> currentUser = user @@ -159,7 +163,9 @@ class ChatImpl( } else { Unit.asFuture() } - }.then { + } + val mutedUsersFuture = mutedUsersManager.loadMutedUsers() + return awaitAll(userFuture, mutedUsersFuture).then { this } } @@ -259,13 +265,7 @@ class ChatImpl( return pubNub.getUUIDMetadata(uuid = userId, includeCustom = true) .then { pnUUIDMetadataResult: PNUUIDMetadataResult -> UserImpl.fromDTO(this, pnUUIDMetadataResult.data) - }.catch { - if (it is PubNubException && it.statusCode == HTTP_ERROR_404) { - Result.success(null) - } else { - Result.failure(it) - } - } + }.nullOn404() } override fun getUsers( @@ -423,13 +423,7 @@ class ChatImpl( return pubNub.getChannelMetadata(channel = channelId, includeCustom = true) .then { pnChannelMetadataResult: PNChannelMetadataResult -> ChannelImpl.fromDTO(this, pnChannelMetadataResult.data) - }.catch { exception -> - if (exception is PubNubException && exception.statusCode == HTTP_ERROR_404) { - Result.success(null) - } else { - Result.failure(exception) - } - } + }.nullOn404() } override fun updateChannel( @@ -660,6 +654,10 @@ class ChatImpl( return } val message = (pnEvent as? MessageResult)?.message ?: return + if (pnEvent.publisher in mutedUsersManager.mutedUsers) { + return + } + val eventContent: EventContent = try { PNDataEncoder.decode(message) } catch (e: Exception) { @@ -717,7 +715,7 @@ class ChatImpl( } val channel: String = INTERNAL_MODERATION_PREFIX + restriction.channelId val userId = restriction.userId - return createChannel(channel).catch { exception -> + return createChannel(channel, type = ChannelType.PUBNUB_PRIVATE).catch { exception -> if (exception.message == CHANNEL_ID_ALREADY_EXIST) { Result.success(Unit) } else { @@ -986,12 +984,16 @@ class ChatImpl( ).then { pnFetchMessagesResult: PNFetchMessagesResult -> val pnFetchMessageItems: List = pnFetchMessagesResult.channelsUrlDecoded[channelId] ?: emptyList() - val events = pnFetchMessageItems.map { pnFetchMessageItem: PNFetchMessageItem -> - EventImpl.fromDTO( - chat = this, - channelId = channelId, - pnFetchMessageItem = pnFetchMessageItem - ) + val events = pnFetchMessageItems.mapNotNull { pnFetchMessageItem: PNFetchMessageItem -> + if (pnFetchMessageItem.uuid in mutedUsersManager.mutedUsers) { + null + } else { + EventImpl.fromDTO( + chat = this, + channelId = channelId, + pnFetchMessageItem = pnFetchMessageItem + ) + } } GetEventsHistoryResult(events = events, isMore = (count == pnFetchMessageItems.size)) diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/Constants.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/Constants.kt index 641145e7..defa0dd3 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/Constants.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/Constants.kt @@ -50,3 +50,7 @@ internal const val TYPE_OF_MESSAGE_IS_CUSTOM = "custom" internal const val RESTRICTION_BAN = "ban" internal const val RESTRICTION_MUTE = "mute" internal const val RESTRICTION_REASON = "reason" + +internal const val TYPE_PUBNUB_PRIVATE = "pn.prv" +internal const val PREFIX_PUBNUB_PRIVATE = "PN_PRV." +internal const val SUFFIX_MUTE_1 = "mute1" diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt index 405e2c4c..bd74ff57 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt @@ -40,4 +40,8 @@ class EventImpl( ) } } + + override fun toString(): String { + return "EventImpl(chat=$chat, timetoken=$timetoken, payload=$payload, channelId='$channelId', userId='$userId')" + } } diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/channel/BaseChannel.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/channel/BaseChannel.kt index 848d547c..e04c4031 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/channel/BaseChannel.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/channel/BaseChannel.kt @@ -469,6 +469,9 @@ abstract class BaseChannel( val listener = createEventListener( chat.pubNub, onMessage = { _, pnMessageResult -> + if (pnMessageResult.publisher in chat.mutedUsersManager.mutedUsers) { + return@createEventListener + } try { if ( ( @@ -814,8 +817,12 @@ abstract class BaseChannel( includeMeta = true ).then { pnFetchMessagesResult: PNFetchMessagesResult -> HistoryResponse( - messages = pnFetchMessagesResult.channelsUrlDecoded[channelId]?.map { messageItem: PNFetchMessageItem -> - messageFactory(chat, messageItem, channelId) + messages = pnFetchMessagesResult.channelsUrlDecoded[channelId]?.mapNotNull { messageItem: PNFetchMessageItem -> + if (messageItem.uuid in chat.mutedUsersManager.mutedUsers) { + null + } else { + messageFactory(chat, messageItem, channelId) + } } ?: emptyList(), isMore = pnFetchMessagesResult.channelsUrlDecoded[channelId]?.size == count ) diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/mutelist/MutedUsersManagerImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/mutelist/MutedUsersManagerImpl.kt new file mode 100644 index 00000000..508b2961 --- /dev/null +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/mutelist/MutedUsersManagerImpl.kt @@ -0,0 +1,113 @@ +package com.pubnub.chat.internal.mutelist + +import com.pubnub.api.PubNub +import com.pubnub.api.enums.PNStatusCategory +import com.pubnub.api.models.consumer.PNStatus +import com.pubnub.api.models.consumer.pubsub.objects.PNDeleteUUIDMetadataEventMessage +import com.pubnub.api.models.consumer.pubsub.objects.PNSetUUIDMetadataEventMessage +import com.pubnub.api.utils.PatchValue +import com.pubnub.chat.internal.PREFIX_PUBNUB_PRIVATE +import com.pubnub.chat.internal.SUFFIX_MUTE_1 +import com.pubnub.chat.internal.TYPE_PUBNUB_PRIVATE +import com.pubnub.chat.internal.util.nullOn404 +import com.pubnub.chat.mutelist.MutedUsersManager +import com.pubnub.kmp.CustomObject +import com.pubnub.kmp.PNFuture +import com.pubnub.kmp.asFuture +import com.pubnub.kmp.createCustomObject +import com.pubnub.kmp.createEventListener +import com.pubnub.kmp.createStatusListener +import com.pubnub.kmp.remember +import com.pubnub.kmp.then +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.updateAndGet + +class MutedUsersManagerImpl(val pubNub: PubNub, val userId: String, val syncEnabled: Boolean) : MutedUsersManager { + private val muteSetAtomic = atomic(emptySet()) + override val mutedUsers by muteSetAtomic + + private val userMuteChannelId = "${PREFIX_PUBNUB_PRIVATE}$userId.$SUFFIX_MUTE_1" + + init { + if (syncEnabled) { + pubNub.addListener( + createEventListener( + pubNub, + onObjects = { _, objectEvent -> + when (val message = objectEvent.extractedMessage) { + is PNSetUUIDMetadataEventMessage -> { + if (message.data.id == userMuteChannelId) { + muteSetAtomic.value = customToMutedUsersSet(message.data.custom) + } + } + + is PNDeleteUUIDMetadataEventMessage -> { + if (message.uuid == userMuteChannelId) { + muteSetAtomic.value = emptySet() + } + } + + else -> {} + } + } + ) + ) + pubNub.addListener( + createStatusListener(pubNub) { _, status: PNStatus -> + if (status.category == PNStatusCategory.PNConnectedCategory || status.category == PNStatusCategory.PNSubscriptionChanged) { + if (userMuteChannelId !in pubNub.getSubscribedChannels()) { + // the client might have been offline for a while and missed some updates so load the list first + loadMutedUsers().async { } + pubNub.subscribe(listOf(userMuteChannelId)) + } + } + } + ) + } + } + + fun loadMutedUsers(): PNFuture { + return if (syncEnabled) { + pubNub.getUUIDMetadata( + userMuteChannelId, + includeCustom = true + ).nullOn404().then { + muteSetAtomic.value = customToMutedUsersSet(it?.data?.custom) + } + } else { + Unit.asFuture() + } + } + + override fun muteUser(userId: String): PNFuture { + return updateMutedUsers { currentMutedUserIds -> currentMutedUserIds + userId } + } + + override fun unmuteUser(userId: String): PNFuture { + return updateMutedUsers { currentMutedUserIds -> currentMutedUserIds - userId } + } + + private fun updateMutedUsers(updateFunction: (Set) -> Set): PNFuture { + val newMuteSet = muteSetAtomic.updateAndGet(updateFunction) + return if (syncEnabled) { + pubNub.setUUIDMetadata( + name = userId, + uuid = userMuteChannelId, + includeCustom = false, + type = TYPE_PUBNUB_PRIVATE, + custom = mutedUsersSetToCustom(newMuteSet) + ).then { Unit }.remember() + } else { + Unit.asFuture() + } + } + + private fun customToMutedUsersSet(custom: PatchValue?>?): Set { + val mutedUsersList = custom?.value?.getOrElse("m") { "" } as? String + return mutedUsersList?.split(",")?.filterNot { it.isEmpty() }?.toSet() ?: emptySet() + } + + private fun mutedUsersSetToCustom(set: Set): CustomObject { + return createCustomObject(mapOf("m" to set.joinToString(","))) + } +} diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/util/Utils.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/util/Utils.kt index dfabaac0..40de0790 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/util/Utils.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/util/Utils.kt @@ -4,23 +4,10 @@ import co.touchlab.kermit.Logger import com.pubnub.api.PubNubException import com.pubnub.api.models.consumer.history.PNFetchMessageItem import com.pubnub.api.models.consumer.history.PNFetchMessagesResult - -// internal fun getPhraseToLookFor(text: String, separator: String): String? { -// val lastAtIndex = text.lastIndexOf(separator) -// if (lastAtIndex == -1) { -// return null -// } -// val charactersAfterHash = text.substring(lastAtIndex + 1) -// if (charactersAfterHash.length < 3) { -// return null -// } -// -// val splitWords: List = charactersAfterHash.split(" ") -// if (splitWords.size > 2) { -// return null -// } -// return splitWords.joinToString(" ") -// } +import com.pubnub.api.v2.callbacks.Result +import com.pubnub.chat.internal.HTTP_ERROR_404 +import com.pubnub.kmp.PNFuture +import com.pubnub.kmp.catch internal expect fun urlDecode(encoded: String): String @@ -44,3 +31,11 @@ inline fun Logger.pnError(message: String): Nothing = throw PubNubException(mess inline fun Logger.logErrorAndReturnException(message: String): PubNubException { return PubNubException(message).logErrorAndReturnException(this) } + +internal fun PNFuture.nullOn404() = catch { + if (it is PubNubException && it.statusCode == HTTP_ERROR_404) { + Result.success(null) + } else { + Result.failure(it) + } +} diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/BaseChatIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/BaseChatIntegrationTest.kt index 3382c63f..25c93107 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/BaseChatIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/BaseChatIntegrationTest.kt @@ -16,38 +16,47 @@ import com.pubnub.test.await import com.pubnub.test.randomString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlin.test.AfterTest -import kotlin.test.BeforeTest internal const val CHANNEL_ID_OF_PARENT_MESSAGE_PREFIX = "channelIdOfParentMessage_" internal const val THREAD_CHANNEL_ID_PREFIX = "threadChannel_id_" abstract class BaseChatIntegrationTest : BaseIntegrationTest() { - lateinit var chat: ChatImpl - lateinit var chat02: ChatImpl - lateinit var chatPamServer: ChatImpl - lateinit var chatPamClient: ChatImpl - lateinit var channel01: Channel // this simulates first user in channel01 - lateinit var channel01Chat02: Channel // this simulates second user in channel01 - lateinit var channel02: Channel - lateinit var threadChannel: ThreadChannel - lateinit var channelPam: Channel - lateinit var someUser: User - lateinit var someUser02: User - lateinit var userPamServer: User - lateinit var userPamClient: User + private val channel01Id = randomString() + "!_=-@" + private val usersToRemove = mutableSetOf() + private val usersToRemovePam = mutableSetOf() + private val channelsToRemove = mutableSetOf() + private val channelsToRemovePam = mutableSetOf() - @BeforeTest - override fun before() { - super.before() - chat = ChatImpl(ChatConfiguration(), pubnub) - chat02 = ChatImpl(ChatConfiguration(), pubnub02) - chatPamServer = ChatImpl(ChatConfiguration(), pubnubPamServer) - chatPamClient = ChatImpl(ChatConfiguration(), pubnubPamClient) - val channel01Id = randomString() + "!_=-@" - channel01 = ChannelImpl( + fun createChat(action: () -> ChatImpl): ChatImpl { + return action().also { + usersToRemove.add(it.currentUser.id) + } + } + + fun createPamChat(action: () -> ChatImpl): ChatImpl { + return action().also { + usersToRemovePam.add(it.currentUser.id) + } + } + + val chat: ChatImpl by lazy(LazyThreadSafetyMode.NONE) { + ChatImpl(ChatConfiguration(), pubnub).also { usersToRemove.add(it.currentUser.id) } + } + val chat02: ChatImpl by lazy(LazyThreadSafetyMode.NONE) { + ChatImpl(ChatConfiguration(), pubnub02).also { usersToRemove.add(it.currentUser.id) } + } + val chatPamServer: ChatImpl by lazy(LazyThreadSafetyMode.NONE) { + ChatImpl(ChatConfiguration(), pubnubPamServer).also { usersToRemovePam.add(it.currentUser.id) } + } + val chatPamClient: ChatImpl by lazy(LazyThreadSafetyMode.NONE) { + ChatImpl(ChatConfiguration(), pubnubPamClient).also { usersToRemovePam.add(it.currentUser.id) } + } + val channel01: Channel by lazy(LazyThreadSafetyMode.NONE) { + ChannelImpl( chat = chat, id = channel01Id, name = randomString(), @@ -56,8 +65,10 @@ abstract class BaseChatIntegrationTest : BaseIntegrationTest() { updated = randomString(), status = randomString(), type = ChannelType.DIRECT - ) - channel01Chat02 = ChannelImpl( + ).also { channelsToRemove.add(it.id) } + } + val channel01Chat02: Channel by lazy(LazyThreadSafetyMode.NONE) { + ChannelImpl( chat = chat02, id = channel01Id, name = randomString(), @@ -66,8 +77,10 @@ abstract class BaseChatIntegrationTest : BaseIntegrationTest() { updated = randomString(), status = randomString(), type = ChannelType.DIRECT - ) - channel02 = ChannelImpl( + ).also { channelsToRemove.add(it.id) } + } + val channel02: Channel by lazy(LazyThreadSafetyMode.NONE) { + ChannelImpl( chat = chat, id = randomString() + "!_=-@", name = randomString(), @@ -76,8 +89,10 @@ abstract class BaseChatIntegrationTest : BaseIntegrationTest() { updated = randomString(), status = randomString(), type = ChannelType.DIRECT - ) - threadChannel = ThreadChannelImpl( + ).also { channelsToRemove.add(it.id) } + } + val threadChannel: ThreadChannel by lazy(LazyThreadSafetyMode.NONE) { + ThreadChannelImpl( parentMessage = MessageImpl( chat = chat, timetoken = 123345, @@ -97,7 +112,9 @@ abstract class BaseChatIntegrationTest : BaseIntegrationTest() { status = randomString(), type = ChannelType.DIRECT, ) - channelPam = ChannelImpl( + } + val channelPam: Channel by lazy(LazyThreadSafetyMode.NONE) { + ChannelImpl( chat = chatPamServer, id = randomString() + "!_=-@", name = randomString(), @@ -106,27 +123,40 @@ abstract class BaseChatIntegrationTest : BaseIntegrationTest() { updated = randomString(), status = randomString(), type = ChannelType.DIRECT - ) - // user has chat and chat has user they should be the same? - someUser = chat.currentUser - someUser02 = chat02.currentUser - userPamServer = chatPamServer.currentUser - userPamClient = chatPamClient.currentUser + ).also { channelsToRemovePam.add(it.id) } } + val someUser: User by lazy(LazyThreadSafetyMode.NONE) { chat.currentUser } + val someUser02: User by lazy(LazyThreadSafetyMode.NONE) { chat02.currentUser } + val userPamServer: User by lazy(LazyThreadSafetyMode.NONE) { chatPamServer.currentUser } + val userPamClient: User by lazy(LazyThreadSafetyMode.NONE) { chatPamClient.currentUser } @AfterTest fun afterTest() = runTest { try { - pubnub.removeUUIDMetadata(someUser.id).await() + usersToRemove.forEach { + launch { + pubnub.removeUUIDMetadata(it).await() + } + } + if (PLATFORM != "iOS") { + usersToRemovePam.forEach { + launch { + pubnubPamServer.removeUUIDMetadata(it).await() + } + } + } + channelsToRemove.forEach { + launch { + pubnub.removeChannelMetadata(it).await() + } + } if (PLATFORM != "iOS") { - pubnubPamServer.removeUUIDMetadata(userPamServer.id).await() - pubnubPamServer.removeUUIDMetadata(userPamClient.id).await() + channelsToRemovePam.forEach { + launch { + pubnubPamServer.removeChannelMetadata(it).await() + } + } } - pubnub.removeChannelMetadata(channel01.id).await() - pubnub.removeChannelMetadata(channel01Chat02.id).await() - pubnub.removeChannelMetadata(channel02.id).await() - pubnub.removeChannelMetadata(threadChannel.id).await() - pubnub.removeChannelMetadata(channelPam.id).await() } finally { chat.destroy() chat02.destroy() diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt index 7d9ed19e..89274ffd 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt @@ -9,6 +9,7 @@ import com.pubnub.api.models.consumer.access_manager.v3.UUIDGrant import com.pubnub.api.models.consumer.objects.membership.ChannelMembershipInput import com.pubnub.api.models.consumer.objects.membership.PNChannelMembership import com.pubnub.api.v2.callbacks.Result +import com.pubnub.chat.Chat import com.pubnub.chat.Event import com.pubnub.chat.Membership import com.pubnub.chat.User @@ -16,6 +17,7 @@ import com.pubnub.chat.config.ChatConfiguration import com.pubnub.chat.config.PushNotificationsConfig import com.pubnub.chat.internal.ChatImpl import com.pubnub.chat.internal.INTERNAL_USER_MODERATION_CHANNEL_PREFIX +import com.pubnub.chat.internal.SUFFIX_MUTE_1 import com.pubnub.chat.internal.UserImpl import com.pubnub.chat.internal.error.PubNubErrorMessage import com.pubnub.chat.internal.utils.cyrb53a @@ -67,7 +69,8 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { @Test fun test_storeUserActivityInterval_and_storeUserActivityTimestamps() = runTest { - chat = ChatImpl(ChatConfiguration(storeUserActivityInterval = 100.seconds, storeUserActivityTimestamps = true), pubnub) + val chat = + createChat { ChatImpl(ChatConfiguration(storeUserActivityInterval = 100.seconds, storeUserActivityTimestamps = true), pubnub) } chat.initialize().await() val user: User = chat.getUser(chat.currentUser.id).await()!! @@ -84,12 +87,26 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { pubnubPamClient = createPubNub(configPamClient) val token = chatPamServer.pubNub.grantToken( ttl = 1, - channels = listOf(ChannelGrant.name(get = true, name = "any", read = true, write = true, manage = true)), // get = true - uuids = listOf(UUIDGrant.id(id = pubnubPamClient.configuration.userId.value, get = true, update = true)) // this is important + channels = listOf( + ChannelGrant.name(get = true, name = "any", read = true, write = true, manage = true), + ChannelGrant.name( + name = "PN_PRV.${pubnubPamClient.configuration.userId.value}.$SUFFIX_MUTE_1", + read = true, + ) + ), // get = true + uuids = listOf( + UUIDGrant.id(id = pubnubPamClient.configuration.userId.value, get = true, update = true), + UUIDGrant.id( + id = "PN_PRV.${pubnubPamClient.configuration.userId.value}.$SUFFIX_MUTE_1", + update = true, + delete = true, + get = true, + ) + ) // this is important ).await().token pubnubPamClient.setToken(token) - chatPamClient = ChatImpl(ChatConfiguration(), pubnubPamClient) + val chatPamClient = createPamChat { ChatImpl(ChatConfiguration(), pubnubPamClient) } val initializeChat = chatPamClient.initialize().await() assertEquals(token, initializeChat.pubNub.getToken()) @@ -156,7 +173,7 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { chat.currentUser.asImpl().copy(updated = null, lastActiveTimestamp = null), result.hostMembership.user.asImpl().copy(updated = null, lastActiveTimestamp = null) ) - assertEquals(someUser, result.inviteeMembership.user.asImpl().copy(updated = null, lastActiveTimestamp = null)) + assertEquals(someUser, result.inviteeMembership.user.asImpl()) assertEquals(result.channel, result.hostMembership.channel) assertEquals(result.channel, result.inviteeMembership.channel) @@ -412,14 +429,14 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { apnsEnvironment = PNPushEnvironment.PRODUCTION ) ) - chat = ChatImpl(chatConfig, pubnub) + val chat = createChat { ChatImpl(chatConfig, pubnub) } // remove all pushNotificationChannels chat.unregisterAllPushChannels().await() delayInMillis(1500) // list pushNotification - assertPushChannels(0) + assertPushChannels(chat, 0) // register 3 channels val channel01 = "channel01" @@ -429,21 +446,21 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { delayInMillis(1500) // list pushNotification - assertPushChannels(3) + assertPushChannels(chat, 3) // remove 1 channel chat.unregisterPushChannels(listOf(channel03)).await() delayInMillis(1500) // list pushNotification - assertPushChannels(2) + assertPushChannels(chat, 2) // removeAll chat.unregisterAllPushChannels().await() delayInMillis(1500) // list pushNotification - assertPushChannels(0) + assertPushChannels(chat, 0) } @Test @@ -621,6 +638,9 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { @Test fun setRestrictionThenUnset() = runTest(timeout = 10.seconds) { + if (PLATFORM == "iOS") { + return@runTest + } val userId = someUser.id val channelId = channel01.id val banned = CompletableDeferred() @@ -652,7 +672,7 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { } } - private suspend fun assertPushChannels(expectedNumberOfChannels: Int) { + private suspend fun assertPushChannels(chat: Chat, expectedNumberOfChannels: Int) { val pushChannels = chat.getPushChannels().await() assertEquals(expectedNumberOfChannels, pushChannels.size) } diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MutedUsersIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MutedUsersIntegrationTest.kt new file mode 100644 index 00000000..3d4142fb --- /dev/null +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MutedUsersIntegrationTest.kt @@ -0,0 +1,196 @@ +package com.pubnub.integration + +import com.pubnub.api.models.consumer.access_manager.v3.ChannelGrant +import com.pubnub.api.models.consumer.access_manager.v3.UUIDGrant +import com.pubnub.api.models.consumer.pubsub.PNEvent +import com.pubnub.chat.Event +import com.pubnub.chat.Message +import com.pubnub.chat.config.ChatConfiguration +import com.pubnub.chat.internal.ChatImpl +import com.pubnub.chat.internal.PREFIX_PUBNUB_PRIVATE +import com.pubnub.chat.internal.SUFFIX_MUTE_1 +import com.pubnub.chat.internal.mutelist.MutedUsersManagerImpl +import com.pubnub.chat.listenForEvents +import com.pubnub.chat.mutelist.MutedUsersManager +import com.pubnub.chat.types.EventContent +import com.pubnub.chat.types.GetEventsHistoryResult +import com.pubnub.internal.PLATFORM +import com.pubnub.test.await +import com.pubnub.test.randomString +import com.pubnub.test.test +import delayForHistory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MutedUsersIntegrationTest : BaseChatIntegrationTest() { + private fun getMutedUsers( + sync: Boolean = false + ): MutedUsersManager = MutedUsersManagerImpl(pubnub, pubnub.configuration.userId.value, sync) + + @Test + fun muteUser_adds_user_to_set() { + val mutedUsers = getMutedUsers() + + mutedUsers.muteUser(someUser.id) + + assertContains(mutedUsers.mutedUsers, someUser.id) + + mutedUsers.muteUser(someUser02.id) + + assertContains(mutedUsers.mutedUsers, someUser.id) + assertContains(mutedUsers.mutedUsers, someUser02.id) + } + + @Test + fun unmuteUser_removes_user_from_set() { + val mutedUsers = getMutedUsers() + mutedUsers.muteUser(someUser.id) + mutedUsers.muteUser(someUser02.id) + + mutedUsers.unmuteUser(someUser.id) + + assertContains(mutedUsers.mutedUsers, someUser02.id) + assertFalse { mutedUsers.mutedUsers.contains(someUser.id) } + + mutedUsers.unmuteUser(someUser02.id) + + assertTrue { mutedUsers.mutedUsers.isEmpty() } + } + + @Test + fun sync_updates_between_clients() = runTest { + val mutedUsers1 = getMutedUsers(true) + val mutedUsers2 = MutedUsersManagerImpl(pubnub02, pubnub.configuration.userId.value, true) + pubnub.test(backgroundScope) { + pubnub.awaitSubscribe(listOf("aaa")) // just to kick off connection + pubnub.awaitSubscribe(listOf("${PREFIX_PUBNUB_PRIVATE}${pubnub.configuration.userId.value}.$SUFFIX_MUTE_1")) { + // custom subscription block empty, let mutedUsers subscribe for us + } + mutedUsers2.muteUser(someUser02.id).await() + nextEvent() + assertContains(mutedUsers1.mutedUsers, someUser02.id) + } + } + + @Test + fun sync_updates_between_clients_on_init() = runTest { + val mutedUsers1 = getMutedUsers(true) + mutedUsers1.muteUser(someUser02.id).await() + val chat2 = ChatImpl(ChatConfiguration(syncMutedUsers = true), pubnub).initialize().await() + assertContains(chat2.mutedUsersManager.mutedUsers, someUser02.id) + } + + @Test + fun sync_updates_between_clients_on_init_pam() = runTest { + if (PLATFORM == "iOS") { + return@runTest + } + + val clientUserId = pubnubPamClient.configuration.userId.value + + val serverChat = ChatImpl(ChatConfiguration(), pubnubPamServer).initialize().await() + val token = serverChat.pubNub.grantToken( + ttl = 1, + channels = listOf( + ChannelGrant.name(get = true, name = "anyChannelForNow"), + ChannelGrant.name( + name = "PN_PRV.$clientUserId.mute1", + read = true, + ) + ), + uuids = listOf( + UUIDGrant.id(id = clientUserId, get = true, update = true), + UUIDGrant.id( + id = "PN_PRV.$clientUserId.mute1", + update = true, + delete = true, + get = true, + ) + ) // this is important + ).await().token + + pubnubPamClient.setToken(token) + + ChatImpl(ChatConfiguration(syncMutedUsers = true), pubnubPamClient).initialize().await() + // no exception + } + + @Test + fun connect_filters_muted_users() = runTest { + val channel = chat.createChannel(randomString()).await() + chat.mutedUsersManager.muteUser(chat.currentUser.id) + val messageText = randomString() + val message = CompletableDeferred() + + pubnub.test(backgroundScope, checkAllEvents = false) { + var unsubscribe: AutoCloseable? = null + pubnub.awaitSubscribe(listOf(channel.id)) { + unsubscribe = channel.connect { + message.complete(it) + } + } + channel.sendText(messageText).await() + chat02.getChannel(channel.id).await()?.sendText("text")?.await() + assertEquals(chat02.currentUser.id, message.await().userId) + unsubscribe?.close() + } + } + + @Test + fun getHistory_filters_muted_users() = runTest { + val channel = chat.createChannel(randomString()).await() + chat.mutedUsersManager.muteUser(chat.currentUser.id) + val messageText = randomString() + val tt1 = channel.sendText(messageText).await() + val tt2 = chat02.getChannel(channel.id).await()!!.sendText("text").await() + delayForHistory() + val historyResponse = channel.getHistory(tt2.timetoken + 1, tt1.timetoken).await() + assertEquals(1, historyResponse.messages.size) + assertEquals(chat02.currentUser.id, historyResponse.messages.first().userId) + } + + @Test + fun getEventsHistory_filters_muted_users() = runTest { + // given + val startTimetoken = pubnub.time().await().timetoken + chat.mutedUsersManager.muteUser(chat02.currentUser.id) + channel01Chat02.invite(chat.currentUser).await() + channel01Chat02.sendText( + text = "message01In${channel01.id}", + usersToMention = listOf(chat.currentUser.id) + ).await() + + // when + delayForHistory() + delayForHistory() + val eventsForUser: GetEventsHistoryResult = chat.getEventsHistory( + channelId = chat.currentUser.id, + endTimetoken = startTimetoken + ).await() + + // then + assertTrue { eventsForUser.events.isEmpty() } + } + + @Test + fun listenForEvents_filters_muted_users() = runTest { + // given + chat.mutedUsersManager.muteUser(chat02.currentUser.id) + val completableInvite = CompletableDeferred>() + chat.listenForEvents(chat.currentUser.id) { invite: Event -> + completableInvite.complete(invite) + } + + // when + channel01Chat02.invite(chat.currentUser).await() + delayForHistory() + + // then + assertFalse { completableInvite.isCompleted } + } +} diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/UserIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/UserIntegrationTest.kt index 2cfc5c8f..5f17ea42 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/UserIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/UserIntegrationTest.kt @@ -163,8 +163,8 @@ class UserIntegrationTest : BaseChatIntegrationTest() { val chatConfig = ChatConfiguration( storeUserActivityTimestamps = true ) - val chatNew: Chat = ChatImpl(chatConfig, pubnub).initialize().await() - someUser = chatNew.currentUser + val chatNew: Chat = createChat { ChatImpl(chatConfig, pubnub) }.initialize().await() + val someUser = chatNew.currentUser // when val isUserActive = someUser.active @@ -180,11 +180,11 @@ class UserIntegrationTest : BaseChatIntegrationTest() { storeUserActivityTimestamps = true ) - val chatNew: Chat = ChatImpl(chatConfig, pubnub).initialize().await() + val chatNew: Chat = createChat { ChatImpl(chatConfig, pubnub) }.initialize().await() delayInMillis(2000) // call init second time to simulate user existence val chatNew2: Chat = ChatImpl(chatConfig, pubnub).initialize().await() - someUser = chatNew2.currentUser + val someUser = chatNew2.currentUser // when val isUserActive = someUser.active diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/ChannelTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/ChannelTest.kt index 9f3d38ff..6f588332 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/ChannelTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/ChannelTest.kt @@ -40,6 +40,7 @@ import com.pubnub.chat.internal.UserImpl import com.pubnub.chat.internal.channel.BaseChannel import com.pubnub.chat.internal.channel.ChannelImpl import com.pubnub.chat.internal.message.MessageImpl +import com.pubnub.chat.internal.mutelist.MutedUsersManagerImpl import com.pubnub.chat.internal.timer.createTimerManager import com.pubnub.chat.types.ChannelType import com.pubnub.chat.types.EventContent @@ -103,6 +104,7 @@ class ChannelTest : BaseTest() { every { chat.config } returns chatConfig every { chat.pubNub } returns pubNub + every { chat.mutedUsersManager } returns MutedUsersManagerImpl(pubNub, "demo", false) val timerManager = createTimerManager() every { chat.timerManager } returns timerManager every { pubNub.configuration } returns createPNConfiguration(UserId("demo"), "demo", "demo", authToken = null) @@ -479,7 +481,6 @@ class ChannelTest : BaseTest() { // when objectUnderTest.getHistory(startToken, endToken).async { // then - assertTrue { it.isSuccess } it.onSuccess { result: HistoryResponse -> assertEquals( listOf( @@ -504,6 +505,8 @@ class ChannelTest : BaseTest() { ), result.messages ) + }.onFailure { + throw it } } } diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt index 0acde9f8..c4298020 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt @@ -22,6 +22,7 @@ import com.pubnub.chat.internal.timer.TimerManager import com.pubnub.chat.internal.timer.createTimerManager import com.pubnub.chat.message.GetUnreadMessagesCounts import com.pubnub.chat.message.MarkAllMessageAsReadResponse +import com.pubnub.chat.mutelist.MutedUsersManager import com.pubnub.chat.restrictions.Restriction import com.pubnub.chat.types.ChannelType import com.pubnub.chat.types.CreateGroupConversationResult @@ -38,6 +39,9 @@ import kotlin.reflect.KClass abstract class FakeChat(override val config: ChatConfiguration, override val pubNub: PubNub) : ChatInternal { override val timerManager: TimerManager = createTimerManager() + override val mutedUsersManager: MutedUsersManager + get() = TODO("Not yet implemented") + override fun destroy() { TODO("Not yet implemented") } diff --git a/pubnub-chat-impl/src/jsMain/kotlin/ChatJs.kt b/pubnub-chat-impl/src/jsMain/kotlin/ChatJs.kt index 52d59c2d..9a5b70d6 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/ChatJs.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/ChatJs.kt @@ -30,6 +30,8 @@ class ChatJs internal constructor(val chat: ChatInternal, val config: ChatConfig val sdk: PubNub get() = (chat.pubNub as PubNubImpl).jsPubNub + val mutedUsersManager: MutedUsersManagerJs get() = MutedUsersManagerJs(chat.mutedUsersManager) + fun emitEvent(event: dynamic): Promise { val channel: String = event.channel ?: event.user val type = event.type diff --git a/pubnub-chat-impl/src/jsMain/kotlin/MutedUsersManagerJs.kt b/pubnub-chat-impl/src/jsMain/kotlin/MutedUsersManagerJs.kt new file mode 100644 index 00000000..dcc442c4 --- /dev/null +++ b/pubnub-chat-impl/src/jsMain/kotlin/MutedUsersManagerJs.kt @@ -0,0 +1,16 @@ +@file:OptIn(ExperimentalJsExport::class) + +import com.pubnub.chat.mutelist.MutedUsersManager +import kotlin.js.Promise + +@JsExport +@JsName("MutedUsersManager") +class MutedUsersManagerJs internal constructor(private val mutedUsersManager: MutedUsersManager) { + val mutedUsers: Array get() { + return mutedUsersManager.mutedUsers.toTypedArray() + } + + fun muteUser(userId: String): Promise = mutedUsersManager.muteUser(userId).asPromise() + + fun unmuteUser(userId: String): Promise = mutedUsersManager.unmuteUser(userId).asPromise() +} diff --git a/pubnub-chat-impl/src/jsMain/kotlin/QuotedMessageJs.kt b/pubnub-chat-impl/src/jsMain/kotlin/QuotedMessageJs.kt new file mode 100644 index 00000000..5a323f72 --- /dev/null +++ b/pubnub-chat-impl/src/jsMain/kotlin/QuotedMessageJs.kt @@ -0,0 +1,14 @@ +import com.pubnub.chat.internal.MessageDraftImpl + +@OptIn(ExperimentalJsExport::class) +@JsExport +@JsName("QuotedMessage") +class QuotedMessageJs( + val timetoken: String, + val text: String, + val userId: String, +) { + fun getMessageElements(): Array { + return MessageDraftImpl.getMessageElements(text).toJs() + } +} diff --git a/pubnub-chat-impl/src/jsMain/kotlin/converters.kt b/pubnub-chat-impl/src/jsMain/kotlin/converters.kt index aa9cd45e..adccdd90 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/converters.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/converters.kt @@ -67,13 +67,11 @@ internal fun getAscOrDesc(direction: String, field: T): PNSortKe } } -internal fun QuotedMessage.toJs(): QuotedMessageJs { - return createJsObject { - this.text = this@toJs.text - this.userId = this@toJs.userId - this.timetoken = this@toJs.timetoken.toString() - } -} +internal fun QuotedMessage.toJs(): QuotedMessageJs = QuotedMessageJs( + timetoken = timetoken.toString(), + text = text, + userId = userId +) internal inline fun @Serializable T.toJsObject(): JsMap { return createJsonElement(PNDataEncoder.encode(this) as Map).value.unsafeCast>() @@ -167,11 +165,12 @@ internal fun ChatConfig.toChatConfiguration(): ChatConfiguration { return ChatConfiguration( typingTimeout = typingTimeout?.milliseconds ?: 5.seconds, storeUserActivityInterval = storeUserActivityInterval?.milliseconds ?: 600.seconds, - storeUserActivityTimestamps = storeUserActivityTimestamps ?: false, + storeUserActivityTimestamps = storeUserActivityTimestamps == true, pushNotifications = pushNotifications.toKmp(), rateLimitFactor = rateLimitFactor ?: 2, rateLimitPerChannel = rateLimitPerChannel.toKmp(), customPayloads = customPayloads.toKmp(), + syncMutedUsers = syncMutedUsers == true ) } diff --git a/pubnub-chat-impl/src/jsMain/kotlin/types.kt b/pubnub-chat-impl/src/jsMain/kotlin/types.kt index 4fbaeeda..c2b60eb4 100644 --- a/pubnub-chat-impl/src/jsMain/kotlin/types.kt +++ b/pubnub-chat-impl/src/jsMain/kotlin/types.kt @@ -54,12 +54,6 @@ external interface GetUnreadMessagesCountsJs { var count: Double } -external interface QuotedMessageJs { - var timetoken: String - var text: String - var userId: String -} - external interface CreateGroupConversationResultJs { var channel: ChannelJs var hostMembership: MembershipJs @@ -174,6 +168,7 @@ external interface ChatConfig { val rateLimitPerChannel: RateLimitPerChannelJs? val errorLogger: Any? val customPayloads: CustomPayloadsJs? + val syncMutedUsers: Boolean? } external interface CustomPayloadsJs { diff --git a/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt b/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt index 0c72d0b0..5c048531 100644 --- a/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt +++ b/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt @@ -207,7 +207,14 @@ class PubNubTest( cont.invokeOnCancellation { pubNub.removeListener(statusListener) } + val resumeImmediately = getSubscribedChannels().containsAll(channels) && getSubscribedChannelGroups().containsAll( + channelGroups + ) + customSubscriptionBlock() + if (resumeImmediately) { + cont.resume(Unit) + } } suspend fun PubNub.awaitUnsubscribe( diff --git a/src/commonMain/kotlin/com/pubnub/chat/mediators.kt b/src/commonMain/kotlin/com/pubnub/chat/mediators.kt index c7324641..d1a5dddb 100644 --- a/src/commonMain/kotlin/com/pubnub/chat/mediators.kt +++ b/src/commonMain/kotlin/com/pubnub/chat/mediators.kt @@ -6,6 +6,7 @@ import com.pubnub.chat.internal.MessageDraftImpl import com.pubnub.chat.internal.UserImpl import com.pubnub.chat.internal.channel.BaseChannel import com.pubnub.chat.internal.message.BaseMessage +import com.pubnub.chat.types.QuotedMessage /** * Receive updates when specific messages and related message reactions are added, edited, or removed. @@ -102,3 +103,7 @@ fun Channel.createMessageDraft( fun Message.getMessageElements(): List { return MessageDraftImpl.getMessageElements(text) } + +fun QuotedMessage.getMessageElements(): List { + return MessageDraftImpl.getMessageElements(text) +} diff --git a/src/jsMain/resources/index.d.ts b/src/jsMain/resources/index.d.ts index af39f0f9..ded58636 100644 --- a/src/jsMain/resources/index.d.ts +++ b/src/jsMain/resources/index.d.ts @@ -352,7 +352,7 @@ declare class Message { get referencedChannels(): any; get textLinks(): any; get type(): MessageType; - get quotedMessage(): any; + get quotedMessage(): QuotedMessage | null | undefined; get files(): { name: string; id: string; @@ -650,6 +650,7 @@ type ChatConfig = { reactionsActionName?: string; }; authKey?: string; + syncMutedUsers?: boolean; }; type ChatConstructor = Partial & PubNub.PubnubConfig; declare class Chat { @@ -670,6 +671,7 @@ declare class Chat { events: Event[]; isMore: boolean; }>; + get mutedUsersManager(): MutedUsersManager; /** * Current user */ @@ -817,6 +819,20 @@ declare class CryptoUtils { decryptor: (encryptedContent: string) => TextMessageContent; }): Message; } + +declare class QuotedMessage { + get timetoken(): string; + get userId(): string; + get text(): string; + getMessageElements(): MixedTextTypedElement[]; +} + +declare class MutedUsersManager { + get mutedUsers(): string[]; + async muteUser(userId: string); + async unmuteUser(userId: string); +} + declare const MESSAGE_THREAD_ID_PREFIX = "PUBNUB_INTERNAL_THREAD"; declare const INTERNAL_MODERATION_PREFIX = "PUBNUB_INTERNAL_MODERATION_"; declare const INTERNAL_ADMIN_CHANNEL = "PUBNUB_INTERNAL_ADMIN_CHANNEL"; diff --git a/src/jvmTest/kotlin/compubnub/chat/ChatIntegrationTest.kt b/src/jvmTest/kotlin/compubnub/chat/ChatIntegrationTest.kt index cf859fef..43ad46f6 100644 --- a/src/jvmTest/kotlin/compubnub/chat/ChatIntegrationTest.kt +++ b/src/jvmTest/kotlin/compubnub/chat/ChatIntegrationTest.kt @@ -70,8 +70,22 @@ class ChatIntegrationTest : BaseIntegrationTest() { val serverChat = Chat.init(ChatConfiguration(), configPamServer).await() val token = serverChat.pubNub.grantToken( ttl = 1, - channels = listOf(ChannelGrant.name(get = true, name = "anyChannelForNow")), - uuids = listOf(UUIDGrant.id(id = clientUserId, get = true, update = true)) // this is important + channels = listOf( + ChannelGrant.name(get = true, name = "anyChannelForNow"), + ChannelGrant.name( + name = "PN_PRV.$clientUserId.mute1", + read = true, + ) + ), + uuids = listOf( + UUIDGrant.id(id = clientUserId, get = true, update = true), + UUIDGrant.id( + id = "PN_PRV.$clientUserId.mute1", + update = true, + delete = true, + get = true, + ) + ) // this is important ).await().token val configPamClient: PNConfiguration = PNConfiguration.builder(UserId(clientUserId), Keys.pamSubKey) {