Skip to content

Commit

Permalink
Client side mute list (#160)
Browse files Browse the repository at this point in the history
* 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"
  • Loading branch information
wkal-pubnub authored Jan 16, 2025
1 parent c3c0ada commit 6128fad
Show file tree
Hide file tree
Showing 31 changed files with 725 additions and 124 deletions.
1 change: 1 addition & 0 deletions api/pubnub-chat.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion js-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
55 changes: 55 additions & 0 deletions js-chat/tests/mutelist.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})

})
13 changes: 11 additions & 2 deletions pubnub-chat-api/api/pubnub-chat-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 <init> (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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -83,6 +103,7 @@ fun ChatConfiguration(
rateLimitFactor: Int = 2,
rateLimitPerChannel: Map<ChannelType, Duration> = RateLimitPerChannel(),
customPayloads: CustomPayloads? = null,
syncMutedUsers: Boolean = false,
): ChatConfiguration = object : ChatConfiguration {
override val logLevel: LogLevel = logLevel
override val typingTimeout: Duration = typingTimeout
Expand All @@ -92,6 +113,7 @@ fun ChatConfiguration(
override val rateLimitFactor: Int = rateLimitFactor
override val rateLimitPerChannel: Map<ChannelType, Duration> = rateLimitPerChannel
override val customPayloads: CustomPayloads? = customPayloads
override val syncMutedUsers: Boolean = syncMutedUsers
}

typealias RateLimitPerChannel = Map<ChannelType, Duration>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>

/**
* 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<Unit>

/**
* 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<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion pubnub-chat-impl/config/ktlint/baseline.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<baseline version="1.0">
<file name="src/jsMain/kotlin/converters.kt">
<error line="147" column="14" source="standard:function-naming" />
<error line="145" column="14" source="standard:function-naming" />
</file>
</baseline>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, List<Channel>> = mutableMapOf()
private val suggestedUsersCache: MutableMap<String, List<User>> = mutableMapOf()

Expand All @@ -149,7 +153,7 @@ class ChatImpl(
}

fun initialize(): PNFuture<Chat> {
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
Expand All @@ -159,7 +163,9 @@ class ChatImpl(
} else {
Unit.asFuture()
}
}.then {
}
val mutedUsersFuture = mutedUsersManager.loadMutedUsers()
return awaitAll(userFuture, mutedUsersFuture).then {
this
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<EventContent>(message)
} catch (e: Exception) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -986,12 +984,16 @@ class ChatImpl(
).then { pnFetchMessagesResult: PNFetchMessagesResult ->
val pnFetchMessageItems: List<PNFetchMessageItem> =
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))
Expand Down
Loading

0 comments on commit 6128fad

Please sign in to comment.