From e9639626a6abd87aa23563bf9648b6c8f7e0c844 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Sun, 16 Aug 2026 23:47:22 +0530 Subject: [PATCH 1/3] feat(pin-save-thread-subscription-js-sdk): add documentation for pin/save messages, pin conversations and thread subscription --- docs.json | 4 + sdk/javascript/all-real-time-listeners.mdx | 84 ++++- sdk/javascript/message-filtering.mdx | 95 ++++++ sdk/javascript/pin-conversation.mdx | 284 +++++++++++++++++ sdk/javascript/pin-message.mdx | 298 ++++++++++++++++++ sdk/javascript/save-message.mdx | 302 ++++++++++++++++++ sdk/javascript/thread-subscription.mdx | 349 +++++++++++++++++++++ sdk/javascript/threaded-messages.mdx | 69 ++++ 8 files changed, 1481 insertions(+), 4 deletions(-) create mode 100644 sdk/javascript/pin-conversation.mdx create mode 100644 sdk/javascript/pin-message.mdx create mode 100644 sdk/javascript/save-message.mdx create mode 100644 sdk/javascript/thread-subscription.mdx diff --git a/docs.json b/docs.json index cb43da08b..e45eaec60 100644 --- a/docs.json +++ b/docs.json @@ -3226,10 +3226,14 @@ "sdk/javascript/message-filtering", "sdk/javascript/retrieve-conversations", "sdk/javascript/threaded-messages", + "sdk/javascript/thread-subscription", "sdk/javascript/edit-message", "sdk/javascript/delete-message", "sdk/javascript/flag-message", + "sdk/javascript/pin-message", + "sdk/javascript/save-message", "sdk/javascript/delete-conversation", + "sdk/javascript/pin-conversation", "sdk/javascript/typing-indicators", "sdk/javascript/transient-messages", "sdk/javascript/delivery-read-receipts", diff --git a/sdk/javascript/all-real-time-listeners.mdx b/sdk/javascript/all-real-time-listeners.mdx index bd78c0c1e..d24596d7f 100644 --- a/sdk/javascript/all-real-time-listeners.mdx +++ b/sdk/javascript/all-real-time-listeners.mdx @@ -3,14 +3,15 @@ title: "All Real Time Listeners" description: "Use CometChat real-time listeners for user presence, groups, messages, calls, AI assistant events, and ongoing calls." --- -CometChat provides 4 listeners viz. +CometChat provides the following listeners. 1. [User Listener](/sdk/javascript/all-real-time-listeners#user-listener) 2. [Group Listener](/sdk/javascript/all-real-time-listeners#group-listener) 3. [Message Listener](/sdk/javascript/all-real-time-listeners#message-listener) -4. [Call Listener](/sdk/javascript/all-real-time-listeners#call-listener) -5. [AI Assistant Listener](/sdk/javascript/all-real-time-listeners#ai-assistant-listener) -6. [Ongoing Call Listener (Calls SDK)](/sdk/javascript/all-real-time-listeners#ongoing-call-listener-calls-sdk) +4. [Conversation Listener](/sdk/javascript/all-real-time-listeners#conversation-listener) +5. [Call Listener](/sdk/javascript/all-real-time-listeners#call-listener) +6. [AI Assistant Listener](/sdk/javascript/all-real-time-listeners#ai-assistant-listener) +7. [Ongoing Call Listener (Calls SDK)](/sdk/javascript/all-real-time-listeners#ongoing-call-listener-calls-sdk) ## User Listener @@ -308,6 +309,10 @@ Receive events for incoming messages, typing indicators, read/delivery receipts, | **onMessagesDeliveredToAll(receipt: CometChat.MessageReceipt)** | This event is triggered when a group message is delivered to all members. Group conversations only. | | **onMessagesReadByAll(receipt: CometChat.MessageReceipt)** | This event is triggered when a group message is read by all members. Group conversations only. | | **onAIAssistantMessageReceived(message: CometChat.AIAssistantMessage)** | This event is triggered when a persisted AI assistant reply is received after an agent run completes. | +| **onMessagePinned(message: CometChat.BaseMessage)** | This event is triggered when a message is pinned in a user/group conversation. Broadcast to everyone in that conversation. | +| **onMessageUnpinned(message: CometChat.BaseMessage)** | This event is triggered when a message is unpinned in a user/group conversation. | +| **onMessageSaved(message: CometChat.BaseMessage)** | This event is triggered when the logged-in user saves a message. Private and delivered to that user's other devices only. | +| **onMessageUnsaved(message: CometChat.BaseMessage)** | This event is triggered when the logged-in user unsaves a message. | | **onAIToolResultReceived(message: CometChat.AIToolResultMessage)** | This event is triggered when a persisted AI tool result message is received after an agent run completes. | | **onAIToolArgumentsReceived(message: CometChat.AIToolArgumentMessage)** | This event is triggered when a persisted AI tool argument message is received after an agent run completes. | | **onCardMessageReceived(message: CometChat.CardMessage)** | This event is triggered when a standalone [card message](/sdk/javascript/card-messages) is received. | @@ -474,6 +479,77 @@ CometChat.removeMessageListener(listenerID); +## Conversation Listener + +Receive events when a conversation is pinned or unpinned. This is a separate channel from `MessageListener` because its payload is a `Conversation`, not a message. + +| Method | Information | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **onConversationPinned(conversation: CometChat.Conversation)** | Triggered when a conversation is pinned — by the logged-in user on another device, or globally by an admin. | +| **onConversationUnpinned(conversation: CometChat.Conversation)** | Triggered when a conversation is unpinned. The `Conversation` arrives with its pin attributes cleared. | + + + +```typescript +let listenerID: string = "UNIQUE_LISTENER_ID"; + +CometChat.addConversationListener( + listenerID, + new CometChat.ConversationListener({ + onConversationPinned: (conversation: CometChat.Conversation) => { + console.log("Conversation pinned", conversation); + }, + onConversationUnpinned: (conversation: CometChat.Conversation) => { + console.log("Conversation unpinned", conversation); + }, + }) +); +``` + + + + +```javascript +let listenerID = "UNIQUE_LISTENER_ID"; + +CometChat.addConversationListener( + listenerID, + new CometChat.ConversationListener({ + onConversationPinned: (conversation) => { + console.log("Conversation pinned", conversation); + }, + onConversationUnpinned: (conversation) => { + console.log("Conversation unpinned", conversation); + }, + }) +); +``` + + + + + +Remove the listener once you are done: + + + +```typescript +CometChat.removeConversationListener("UNIQUE_LISTENER_ID"); +``` + + + + +```javascript +CometChat.removeConversationListener("UNIQUE_LISTENER_ID"); +``` + + + + + +See [Pin A Conversation](/sdk/javascript/pin-conversation) for the full feature. + ## Call Listener Receive events for incoming and outgoing call state changes. diff --git a/sdk/javascript/message-filtering.mdx b/sdk/javascript/message-filtering.mdx index 595b6964c..2c377e898 100644 --- a/sdk/javascript/message-filtering.mdx +++ b/sdk/javascript/message-filtering.mdx @@ -713,6 +713,101 @@ let messagesRequest = new CometChat.MessagesRequestBuilder() The above code returns messages belonging to the thread with the specified parent message ID. +## Pinned messages + +Use `setPinned(true)` to fetch only the pinned messages of a conversation. A pinned list is conversation-scoped, so pair it with `setUID()` or `setGUID()` — exactly one of the two is required. + + + +```typescript +let UID: string = "UID", + limit: number = 50, + messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setUID(UID) + .setLimit(limit) + .setPinned(true) + .build(); +``` + + + + +```javascript +let UID = "UID"; +let limit = 50; +let messagesRequest = new CometChat.MessagesRequestBuilder() + .setUID(UID) + .setLimit(limit) + .setPinned(true) + .build(); +``` + + + + +```typescript +let GUID: string = "GUID", + limit: number = 50, + messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setGUID(GUID) + .setLimit(limit) + .setPinned(true) + .build(); +``` + + + + +```javascript +let GUID = "GUID"; +let limit = 50; +let messagesRequest = new CometChat.MessagesRequestBuilder() + .setGUID(GUID) + .setLimit(limit) + .setPinned(true) + .build(); +``` + + + + + +The list is ordered by pin time, most recently pinned first — not by when the messages were sent. See [Pin A Message](/sdk/javascript/pin-message) for the full feature. + +## Saved messages + +Use `setSaved(true)` to fetch the logged-in user's saved messages. The saved list is user-level and spans every conversation, so unlike the pinned list you do **not** set a UID or a GUID. + + + +```typescript +let limit: number = 50, + messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setLimit(limit) + .setSaved(true) + .build(); +``` + + + + +```javascript +let limit = 50; +let messagesRequest = new CometChat.MessagesRequestBuilder() + .setLimit(limit) + .setSaved(true) + .build(); +``` + + + + + +Saves are private to the logged-in user. Because the rows come from different conversations, use `getConversationId()`, `getReceiverType()` and `getReceiverId()` on each message to route back to the right conversation. See [Save A Message](/sdk/javascript/save-message) for the full feature. + ## Hide threaded messages in user/group conversations Use `hideReplies(true)` to exclude threaded messages from the main conversation. Default is `false`. diff --git a/sdk/javascript/pin-conversation.mdx b/sdk/javascript/pin-conversation.mdx new file mode 100644 index 000000000..803dec74d --- /dev/null +++ b/sdk/javascript/pin-conversation.mdx @@ -0,0 +1,284 @@ +--- +title: "Pin A Conversation" +description: "Pin and unpin conversations, fetch the pinned conversation list, and listen for conversation pin events with the CometChat JavaScript SDK." +--- + +Pinning a conversation keeps it at the top of the logged-in user's conversation list. The pin is **private to that user** — nobody else sees it — and it syncs to their other devices. + + + This is separate from an **admin-global pin**, which is managed from the + [CometChat Dashboard](https://app.cometchat.com) and shows for every user. Those + cannot be created or removed from the SDK, only observed. + + +## Pin a Conversation + +A conversation is addressed by its peer — the other user's UID for a one-on-one conversation, or the GUID for a group — together with the conversation type. + + + + ```typescript + CometChat.pinConversation("cometchat-uid-1", CometChat.RECEIVER_TYPE.USER).then( + (conversation: CometChat.Conversation) => { + console.log("Conversation pinned:", conversation); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to pin conversation:", error); + } + ); + ``` + + + ```javascript + CometChat.pinConversation("cometchat-guid-1", CometChat.RECEIVER_TYPE.GROUP).then( + (conversation) => { + console.log("Conversation pinned:", conversation); + }, + (error) => { + console.log("Failed to pin conversation:", error); + } + ); + ``` + + + +It resolves with the full updated `Conversation`, with `pinnedAt` and `pinnedBy` set. Pinning is idempotent. + + + Addressing by peer rather than by `conversationId` is deliberate: it lets you + pin a conversation that has no messages yet. + + +## Unpin a Conversation + + + + ```typescript + CometChat.unpinConversation( + "cometchat-uid-1", + CometChat.RECEIVER_TYPE.USER + ).then( + (conversation: CometChat.Conversation) => { + console.log("Conversation unpinned:", conversation); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to unpin conversation:", error); + } + ); + ``` + + + ```javascript + CometChat.unpinConversation( + "cometchat-uid-1", + CometChat.RECEIVER_TYPE.USER + ).then( + (conversation) => { + console.log("Conversation unpinned:", conversation); + }, + (error) => { + console.log("Failed to unpin conversation:", error); + } + ); + ``` + + + +A user cannot unpin an admin-global pin — that call is rejected with `ERR_ACTION_NOT_ALLOWED`. Hide or disable the unpin control for conversations you know are system-pinned. + +## Fetch Pinned Conversations + +The default conversation list is already **pin-ordered** by the server: admin-global pins first, then the user's own pins, then everything else. Fetch it as you normally would and no extra filter is needed. + +To narrow the list to pins only, use `setPinnedBy()` with the tokens on `CometChat.PINNED_BY`. + + + + ```typescript + let conversationsRequest: CometChat.ConversationsRequest = + new CometChat.ConversationsRequestBuilder() + .setPinnedBy([CometChat.PINNED_BY.SYSTEM, CometChat.PINNED_BY.ME]) + .setLimit(30) + .build(); + + conversationsRequest.fetchNext().then( + (conversations: CometChat.Conversation[]) => { + console.log("Pinned conversations:", conversations); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to fetch conversations:", error); + } + ); + ``` + + + ```javascript + let conversationsRequest = new CometChat.ConversationsRequestBuilder() + .setPinnedBy([CometChat.PINNED_BY.ME]) + .setLimit(30) + .build(); + + conversationsRequest.fetchNext().then( + (conversations) => { + console.log("Pinned conversations:", conversations); + }, + (error) => { + console.log("Failed to fetch conversations:", error); + } + ); + ``` + + + +| Token | Meaning | +| ------------------------- | ---------------------------------------------------- | +| `CometChat.PINNED_BY.ME` | Conversations the logged-in user pinned themselves. | +| `CometChat.PINNED_BY.SYSTEM` | Conversations pinned globally by an admin. | + +Passing an empty array is the same as not calling `setPinnedBy()` at all — you get the default, pin-ordered list. + +## Check if a Conversation is Pinned + + + + ```typescript + if (conversation.isPinned()) { + console.log("Pinned at:", conversation.getPinnedAt()); + console.log("Pinned by:", conversation.getPinnedBy()); + } + ``` + + + ```javascript + if (conversation.isPinned()) { + console.log("Pinned at:", conversation.getPinnedAt()); + console.log("Pinned by:", conversation.getPinnedBy()); + } + ``` + + + +As with messages, the presence of `pinnedAt` *is* the boolean — an unpinned conversation has no pin attributes at all. + +## Real-time Conversation Pin Events + +Conversation pins arrive on a dedicated `ConversationListener`, **not** on `MessageListener` — the payload is a `Conversation`, not a message. + + + + ```typescript + CometChat.addConversationListener( + "UNIQUE_LISTENER_ID", + new CometChat.ConversationListener({ + onConversationPinned: (conversation: CometChat.Conversation) => { + console.log("Conversation pinned:", conversation); + }, + onConversationUnpinned: (conversation: CometChat.Conversation) => { + console.log("Conversation unpinned:", conversation); + }, + }) + ); + ``` + + + ```javascript + CometChat.addConversationListener( + "UNIQUE_LISTENER_ID", + new CometChat.ConversationListener({ + onConversationPinned: (conversation) => { + console.log("Conversation pinned:", conversation); + }, + onConversationUnpinned: (conversation) => { + console.log("Conversation unpinned:", conversation); + }, + }) + ); + ``` + + + +These fire both when the logged-in user pins from another device and when an admin pins globally. Remove the listener when you are done: + + + + ```typescript + CometChat.removeConversationListener("UNIQUE_LISTENER_ID"); + ``` + + + ```javascript + CometChat.removeConversationListener("UNIQUE_LISTENER_ID"); + ``` + + + +## Pin Limit + + + + ```typescript + let limit: number | null = await CometChat.getPinnedConversationsLimit(); + let systemLimit: number | null = + await CometChat.getSystemPinnedConversationsLimit(); + ``` + + + ```javascript + CometChat.getPinnedConversationsLimit().then((limit) => { + console.log("Pinned conversations limit:", limit); + }); + ``` + + + +Both resolve to `null` when the app settings carry no value. `getSystemPinnedConversationsLimit()` is the separate admin/global cap, enforced independently. + +## Feature Availability + + + + ```typescript + let enabled: boolean = await CometChat.isPinConversationEnabled(); + + if (enabled) { + // Show the Pin Conversation option + } + ``` + + + ```javascript + CometChat.isPinConversationEnabled().then((enabled) => { + if (enabled) { + // Show the Pin Conversation option + } + }); + ``` + + + +--- + +## Next Steps + + + + Fetch and order the conversation list + + + Highlight a message for everyone in a conversation + + + Bookmark a message privately, across conversations + + + Every listener the SDK exposes, in one place + + diff --git a/sdk/javascript/pin-message.mdx b/sdk/javascript/pin-message.mdx new file mode 100644 index 000000000..a22837e7e --- /dev/null +++ b/sdk/javascript/pin-message.mdx @@ -0,0 +1,298 @@ +--- +title: "Pin A Message" +description: "Pin and unpin messages in a conversation, fetch the pinned list, and listen for pin events with the CometChat JavaScript SDK." +--- + +Pinning highlights an important message in a conversation. A pin is **conversation-wide and visible to everyone** in that conversation, so it is the right tool for announcements, rules or a link everyone keeps asking for. + + + Pinning is a moderation action. Only an Admin or Moderator — a group owner + included — may pin or unpin. The server is the authority: a member's call is + rejected with `ERR_ACTION_NOT_ALLOWED`. + + +## Pin a Message + +Call `pinMessage()` with the message's ID. It resolves with the full updated message, with `pinnedAt` and `pinnedBy` set. + + + + ```typescript + let messageId: number = 100; + + CometChat.pinMessage(messageId).then( + (message: CometChat.BaseMessage) => { + console.log("Message pinned:", message); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to pin message:", error); + } + ); + ``` + + + ```javascript + let messageId = 100; + + CometChat.pinMessage(messageId).then( + (message) => { + console.log("Message pinned:", message); + }, + (error) => { + console.log("Failed to pin message:", error); + } + ); + ``` + + + +Pinning is **idempotent**, and a message has a single pinner: re-pinning an already pinned message updates `pinnedBy` and `pinnedAt` to the most recent pinner rather than failing. + +## Unpin a Message + + + + ```typescript + CometChat.unpinMessage(100).then( + (message: CometChat.BaseMessage) => { + console.log("Message unpinned:", message); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to unpin message:", error); + } + ); + ``` + + + ```javascript + CometChat.unpinMessage(100).then( + (message) => { + console.log("Message unpinned:", message); + }, + (error) => { + console.log("Failed to unpin message:", error); + } + ); + ``` + + + +The resolved message comes back with its pin attributes cleared, so you can swap the rendered message straight into your list. + +## Fetch Pinned Messages + +Build a `MessagesRequest` with `setPinned(true)`. A pinned list belongs to one conversation, so pair it with `setUID()` for a one-on-one conversation or `setGUID()` for a group — exactly one of the two is required. + + + + ```typescript + let messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setUID("cometchat-uid-1") + .setPinned(true) + .setLimit(50) + .build(); + + messagesRequest.fetchPrevious().then( + (messages: CometChat.BaseMessage[]) => { + console.log("Pinned messages:", messages); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to fetch pinned messages:", error); + } + ); + ``` + + + ```javascript + let messagesRequest = new CometChat.MessagesRequestBuilder() + .setGUID("cometchat-guid-1") + .setPinned(true) + .setLimit(50) + .build(); + + messagesRequest.fetchPrevious().then( + (messages) => { + console.log("Pinned messages:", messages); + }, + (error) => { + console.log("Failed to fetch pinned messages:", error); + } + ); + ``` + + + +The list is ordered by **pin time, most recently pinned first** — not by when the messages were sent. Render it in the order the SDK returns it. + + + A conversation can hold at most 100 pinned messages, so `setLimit(100)` fetches + the whole list in a single call — which is what you want when showing a pinned + count in a header. + + +## Check if a Message is Pinned + +Every `BaseMessage` carries its pin state. The presence of `pinnedAt` *is* the boolean — an unpinned message simply has no pin attributes. + + + + ```typescript + if (message.isPinned()) { + console.log("Pinned at:", message.getPinnedAt()); + console.log("Pinned by:", message.getPinnedBy()); + + if (message.isSystemPinned()) { + // Pinned globally by an admin from the Dashboard, + // not by a member of the conversation + } + } + ``` + + + ```javascript + if (message.isPinned()) { + console.log("Pinned at:", message.getPinnedAt()); + console.log("Pinned by:", message.getPinnedBy()); + + if (message.isSystemPinned()) { + // Pinned globally by an admin from the Dashboard, + // not by a member of the conversation + } + } + ``` + + + +| Method | Returns | +| ------------------- | -------------------------------------------------------------------------- | +| `isPinned()` | `true` when the message is pinned. | +| `getPinnedAt()` | The pin timestamp, or `undefined`. | +| `getPinnedBy()` | The UID of the most recent pinner, or `undefined`. | +| `isSystemPinned()` | `true` for an admin/global pin. Render it as a system pin, not as a user. | + +## Real-time Pin Events + +Pin and unpin are broadcast to everyone in the conversation. Add the callbacks to your existing `MessageListener`. + + + + ```typescript + CometChat.addMessageListener( + "UNIQUE_LISTENER_ID", + new CometChat.MessageListener({ + onMessagePinned: (message: CometChat.BaseMessage) => { + console.log("Message pinned:", message); + }, + onMessageUnpinned: (message: CometChat.BaseMessage) => { + console.log("Message unpinned:", message); + }, + }) + ); + ``` + + + ```javascript + CometChat.addMessageListener( + "UNIQUE_LISTENER_ID", + new CometChat.MessageListener({ + onMessagePinned: (message) => { + console.log("Message pinned:", message); + }, + onMessageUnpinned: (message) => { + console.log("Message unpinned:", message); + }, + }) + ); + ``` + + + +Each callback receives the **full updated message**, so you can replace the message in your list without a follow-up fetch. + +## Pin Limit + +A conversation holds a capped number of pins, configurable per app. Read the cap rather than hard-coding it — it is tenant-overridable and will drift. + + + + ```typescript + let limit: number | null = await CometChat.getPinnedMessagesLimit(); + let systemLimit: number | null = + await CometChat.getSystemPinnedMessagesLimit(); + + if (limit !== null && pinnedCount >= limit) { + // Disable the pin control instead of letting the user hit the error + } + ``` + + + ```javascript + CometChat.getPinnedMessagesLimit().then((limit) => { + if (limit !== null && pinnedCount >= limit) { + // Disable the pin control instead of letting the user hit the error + } + }); + ``` + + + +Both resolve to `null` when the app settings carry no value — show generic copy in that case rather than guessing a number. `getSystemPinnedMessagesLimit()` is the separate cap for admin/global pins, enforced independently of the per-user one. + +## Feature Availability + +Check whether Pin Message is enabled for your app before showing pin actions. + + + + ```typescript + let enabled: boolean = await CometChat.isPinMessageEnabled(); + + if (enabled) { + // Show the Pin option + } + ``` + + + ```javascript + CometChat.isPinMessageEnabled().then((enabled) => { + if (enabled) { + // Show the Pin option + } + }); + ``` + + + +This resolves `false` rather than rejecting when the flag is missing or settings are unavailable, so an unavailable setting degrades to "hidden" instead of an unhandled rejection. + +--- + +## Next Steps + + + + Bookmark a message privately, across conversations + + + Pin a conversation to the top of the list + + + Every listener the SDK exposes, in one place + + + Filter messages by pinned, saved, type, tags and more + + diff --git a/sdk/javascript/save-message.mdx b/sdk/javascript/save-message.mdx new file mode 100644 index 000000000..2d8e32c94 --- /dev/null +++ b/sdk/javascript/save-message.mdx @@ -0,0 +1,302 @@ +--- +title: "Save A Message" +description: "Save and unsave messages privately, fetch the saved list across conversations, and listen for save events with the CometChat JavaScript SDK." +--- + +Saving bookmarks a message for the logged-in user. Unlike a pin, a save is **private and cross-conversation**: nobody else can see it, no role is required, and the saved list spans every conversation the user is part of. + + + `savedAt` is per-viewer. It is only ever populated in the acting user's own + context — you will never see another user's saves on a message. + + +## Save a Message + +Call `saveMessage()` with the message's ID. It resolves with the full updated message, with `savedAt` set. + + + + ```typescript + let messageId: number = 100; + + CometChat.saveMessage(messageId).then( + (message: CometChat.BaseMessage) => { + console.log("Message saved:", message); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to save message:", error); + } + ); + ``` + + + ```javascript + let messageId = 100; + + CometChat.saveMessage(messageId).then( + (message) => { + console.log("Message saved:", message); + }, + (error) => { + console.log("Failed to save message:", error); + } + ); + ``` + + + +Saving is idempotent — saving an already saved message succeeds rather than failing. + +## Unsave a Message + + + + ```typescript + CometChat.unsaveMessage(100).then( + (message: CometChat.BaseMessage) => { + console.log("Message unsaved:", message); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to unsave message:", error); + } + ); + ``` + + + ```javascript + CometChat.unsaveMessage(100).then( + (message) => { + console.log("Message unsaved:", message); + }, + (error) => { + console.log("Failed to unsave message:", error); + } + ); + ``` + + + +The resolved message comes back with `savedAt` cleared, never left stale. + +## Fetch Saved Messages + +Build a `MessagesRequest` with `setSaved(true)`. The saved list is **user-level**, so unlike the pinned list you do **not** set a UID or a GUID — leaving both unset is what makes it cross-conversation. + + + + ```typescript + let messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setSaved(true) + .setLimit(50) + .build(); + + messagesRequest.fetchPrevious().then( + (messages: CometChat.BaseMessage[]) => { + console.log("Saved messages:", messages); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to fetch saved messages:", error); + } + ); + ``` + + + ```javascript + let messagesRequest = new CometChat.MessagesRequestBuilder() + .setSaved(true) + .setLimit(50) + .build(); + + messagesRequest.fetchPrevious().then( + (messages) => { + console.log("Saved messages:", messages); + }, + (error) => { + console.log("Failed to fetch saved messages:", error); + } + ); + ``` + + + +The list comes back newest message first. Call `fetchPrevious()` again on the same object to page through older entries. + +Because the list spans conversations, every row carries its own context — use `getConversationId()`, `getReceiverId()` and `getReceiverType()` to route a tap on a saved message back to the right conversation. + + + + ```typescript + messages.forEach((message: CometChat.BaseMessage) => { + console.log( + message.getConversationId(), + message.getReceiverType(), // "user" or "group" + message.getReceiverId() + ); + }); + ``` + + + ```javascript + messages.forEach((message) => { + console.log( + message.getConversationId(), + message.getReceiverType(), // "user" or "group" + message.getReceiverId() + ); + }); + ``` + + + +## Check if a Message is Saved + +The presence of `savedAt` *is* the boolean — an unsaved message simply has no save attribute. + + + + ```typescript + if (message.isSaved()) { + console.log("Saved at:", message.getSavedAt()); + } + ``` + + + ```javascript + if (message.isSaved()) { + console.log("Saved at:", message.getSavedAt()); + } + ``` + + + +| Method | Returns | +| ---------------- | ------------------------------------------ | +| `isSaved()` | `true` when the logged-in user saved it. | +| `getSavedAt()` | The save timestamp, or `undefined`. | + +## Real-time Save Events + +Save and unsave are **private multi-device** events: they are delivered to the user's other logged-in sessions so a save on the phone shows up on the desktop. Add the callbacks to your existing `MessageListener`. + + + + ```typescript + CometChat.addMessageListener( + "UNIQUE_LISTENER_ID", + new CometChat.MessageListener({ + onMessageSaved: (message: CometChat.BaseMessage) => { + console.log("Message saved:", message); + }, + onMessageUnsaved: (message: CometChat.BaseMessage) => { + console.log("Message unsaved:", message); + }, + }) + ); + ``` + + + ```javascript + CometChat.addMessageListener( + "UNIQUE_LISTENER_ID", + new CometChat.MessageListener({ + onMessageSaved: (message) => { + console.log("Message saved:", message); + }, + onMessageUnsaved: (message) => { + console.log("Message unsaved:", message); + }, + }) + ); + ``` + + + + + The device that performed the save also receives its own event exactly once — + the SDK de-duplicates the write's own echo, so you can insert into your saved + list from the callback alone without double-counting. + + +## Save Limit + +A user may save a capped number of messages across all conversations. Read the cap from app settings rather than hard-coding it. + + + + ```typescript + let limit: number | null = await CometChat.getSavedMessagesLimit(); + + if (limit !== null && savedCount >= limit) { + // Disable the save control instead of letting the user hit the error + } + ``` + + + ```javascript + CometChat.getSavedMessagesLimit().then((limit) => { + if (limit !== null && savedCount >= limit) { + // Disable the save control instead of letting the user hit the error + } + }); + ``` + + + +It resolves to `null` when the app settings carry no value — show generic copy in that case rather than guessing a number. + +## Feature Availability + + + + ```typescript + let enabled: boolean = await CometChat.isSaveMessageEnabled(); + + if (enabled) { + // Show the Save option + } + ``` + + + ```javascript + CometChat.isSaveMessageEnabled().then((enabled) => { + if (enabled) { + // Show the Save option + } + }); + ``` + + + +This resolves `false` rather than rejecting when the flag is missing or settings are unavailable. + +--- + +## Next Steps + + + + Highlight a message for everyone in a conversation + + + Pin a conversation to the top of the list + + + Fetch and order the conversation list + + + Filter messages by saved, pinned, type, tags and more + + diff --git a/sdk/javascript/thread-subscription.mdx b/sdk/javascript/thread-subscription.mdx new file mode 100644 index 000000000..9189519c1 --- /dev/null +++ b/sdk/javascript/thread-subscription.mdx @@ -0,0 +1,349 @@ +--- +title: "Thread Subscription" +description: "Subscribe and unsubscribe from message threads, read subscription state off a message, and fetch participated threads with the CometChat JavaScript SDK." +--- + +Thread subscription gives users control over thread noise. A user can **subscribe** to a message thread to be notified of future replies, or **unsubscribe** to mute it. + +The server subscribes a user to a thread automatically when they start it, reply in it, or are @-mentioned in it — and they can explicitly subscribe to any parent message, even one that has no replies yet. + + + Thread subscription builds on [Threaded + Messages](/sdk/javascript/threaded-messages). A thread is identified by the ID + of its **parent message** — there is no separate thread ID. + + +## How state works + +The SDK keeps **no subscription state of its own**. There is no cache and no listener to reconcile: + +- Every message fetch asks the server for the flag, and it arrives on the message — read it with `message.isThreadSubscribed()`. +- `subscribeToThread()` and `unsubscribeFromThread()` resolve when the server has accepted the change. The resolved promise **is** the acknowledgement. + +Your app owns the resulting UI state. That means you decide when to flip a toggle optimistically, and you decide what a thread's state is before you have fetched it. + +## Subscribe to a Thread + +Use `subscribeToThread()` with the ID of the thread's parent message. The call is **idempotent** — subscribing to a thread the user already follows succeeds silently. Subscribing to a message with zero replies is allowed; the user is notified when the first reply arrives. + + + + ```typescript + let parentMessageId: number = 100; + + CometChat.subscribeToThread(parentMessageId).then( + (response: string) => { + // The server has accepted it — flip your toggle here. + console.log("Subscribed to thread:", response); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to subscribe:", error); + } + ); + ``` + + + ```javascript + let parentMessageId = 100; + + CometChat.subscribeToThread(parentMessageId).then( + (response) => { + // The server has accepted it — flip your toggle here. + console.log("Subscribed to thread:", response); + }, + (error) => { + console.log("Failed to subscribe:", error); + } + ); + ``` + + + +## Unsubscribe from a Thread + +Use `unsubscribeFromThread()`. This is idempotent too — unsubscribing from a thread the user does not follow succeeds silently. + + + + ```typescript + CometChat.unsubscribeFromThread(100).then( + (response: string) => { + console.log("Unsubscribed from thread:", response); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to unsubscribe:", error); + } + ); + ``` + + + ```javascript + CometChat.unsubscribeFromThread(100).then( + (response) => { + console.log("Unsubscribed from thread:", response); + }, + (error) => { + console.log("Failed to unsubscribe:", error); + } + ); + ``` + + + + + Unsubscribing is **not sticky**. If the user replies in the thread again, or is + @-mentioned in it, the server re-subscribes them. Do not promise users that they + will never hear about the thread again. + + +Unsubscribing hard-deletes the subscription server-side, so a thread you are showing in a "following" list should be removed from that list when the call resolves. + +## Read the Subscription State + +The state rides the **parent message**. Read it with `isThreadSubscribed()`: + + + + ```typescript + if (parentMessage.isThreadSubscribed()) { + // Show the "Unfollow" affordance + } else { + // Show the "Follow" affordance + } + ``` + + + ```javascript + if (parentMessage.isThreadSubscribed()) { + // Show the "Unfollow" affordance + } else { + // Show the "Follow" affordance + } + ``` + + + +Every message fetch the SDK makes asks the server for this flag, so any message you obtained from `MessagesRequest` or `getMessageDetails()` carries it. + + + A message delivered over the **socket** carries no flag and therefore reads + `false`. That is not a claim that the user is unsubscribed — it means nobody + asked. When you need certainty for a thread you have not fetched (a deep link, + for instance), fetch the parent message with `CometChat.getMessageDetails()` and + read the flag off the result. + + +### You are subscribed to your own messages + +Sending a message subscribes you to the thread it may later grow — there is nothing to call. The message comes back with `threadSubscribed: true`, both in the send response and on later fetches, and **only for you**: the flag is per-viewer, so the same message reads `false` for everybody else until they subscribe themselves. + +That default is what makes the flag meaningful on your own messages. Since it starts out `true`, a `false` on a message **you sent** — read from a fetch, not the socket — is not silence. It means you unsubscribed, and nothing should quietly put you back. + +This only holds for a message you sent and obtained from a fetch. On anyone else's message, or on anything socket-delivered, `false` still just means the server was not asked. + +### Keeping your own copies in sync + +The same thread can be represented by several message objects at once — a row in the message list, the header of an open thread view, an entry in a thread inbox. Because the SDK caches nothing, use `setThreadSubscribed()` to align the copies you hold once you know the answer: + + + + ```typescript + await CometChat.subscribeToThread(100); + + // The server accepted it — bring the objects you are rendering into line. + parentMessage.setThreadSubscribed(true); + ``` + + + ```javascript + CometChat.subscribeToThread(100).then(() => { + // The server accepted it — bring the objects you are rendering into line. + parentMessage.setThreadSubscribed(true); + }); + ``` + + + + + `setThreadSubscribed()` is **local only** — it changes the object in memory and + sends nothing to the server. Use `subscribeToThread()` / + `unsubscribeFromThread()` to change the actual subscription. + + +## Reacting to Replies + +A thread reply is an **ordinary message** with `parentMessageId` set, delivered through the standard `MessageListener` like any other message. There is no separate thread listener. + + + + ```typescript + CometChat.addMessageListener( + "UNIQUE_LISTENER_ID", + new CometChat.MessageListener({ + onTextMessageReceived: (message: CometChat.TextMessage) => { + const parentMessageId = message.getParentMessageId(); + if (parentMessageId) { + // A reply landed in a thread — bump your thread row here. + } + }, + }) + ); + ``` + + + ```javascript + CometChat.addMessageListener( + "UNIQUE_LISTENER_ID", + new CometChat.MessageListener({ + onTextMessageReceived: (message) => { + const parentMessageId = message.getParentMessageId(); + if (parentMessageId) { + // A reply landed in a thread — bump your thread row here. + } + }, + }) + ); + ``` + + + +Using `MessageListener` also gets you `onMessageEdited` and `onMessageDeleted` for replies, which a thread-only channel would not. + +Your own replies do not arrive on a listener — bump your thread row from the `sendMessage()` promise instead. + +## Fetch the Threads a User Participates In + +Use `ThreadsRequest` to build a thread inbox. Every returned thread is one the logged-in user is subscribed to. + + + + ```typescript + let threadsRequest: CometChat.ThreadsRequest = + new CometChat.ThreadsRequestBuilder() + .setParticipatedByMe(true) + .setLimit(30) + .build(); + + threadsRequest.fetchNext().then( + (threads: CometChat.MessageThread[]) => { + console.log("Threads fetched:", threads); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to fetch threads:", error); + } + ); + ``` + + + ```javascript + let threadsRequest = new CometChat.ThreadsRequestBuilder() + .setParticipatedByMe(true) + .setLimit(30) + .build(); + + threadsRequest.fetchNext().then( + (threads) => { + console.log("Threads fetched:", threads); + }, + (error) => { + console.log("Failed to fetch threads:", error); + } + ); + ``` + + + +Call `fetchNext()` repeatedly on the same object to page through the list. Scope the list to one conversation with `setUid()` or `setGuid()` — the two are mutually exclusive. + +| Method | Description | +| --------------------------- | ------------------------------------------------------------------- | +| `setLimit(number)` | Threads per page. Accepts 1–1000. | +| `setParticipatedByMe(bool)` | Restrict the list to threads the logged-in user participates in. | +| `setUid(string)` | Only threads in the one-on-one conversation with this user. | +| `setGuid(string)` | Only threads in this group. | + +### The MessageThread Model + +Each row is a `MessageThread`: + +| Method | Returns | +| ------------------------- | ----------------------------------------------------------------- | +| `getParentMessageId()` | The thread's identifier — the parent message's ID. | +| `getParentMessage()` | The parent `BaseMessage`, or `null` if it could not be parsed. | +| `getReplyCount()` | Number of replies in the thread. | +| `getLastReply()` | The most recent reply, or `null`. | +| `getUnreadReplyCount()` | Unread replies, or `null` when the server did not send a count. | +| `isSubscribed()` | Whether the user follows this thread. Always `true` for list rows. | +| `getUpdatedAt()` | When the thread last changed. | +| `getConversationId()` | The conversation the thread belongs to. | +| `getReceiverType()` | `"user"` or `"group"`. | +| `getReceiverId()` | The peer's UID or the group's GUID. | + + + Sort a thread inbox on `getLastReply()?.getSentAt()` falling back to the parent + message's `sentAt` — a thread with no replies has no last reply. + + +## Error Handling + +Both `subscribeToThread()` and `unsubscribeFromThread()` reject with a `CometChatException`. The most common client-side failure is an invalid parent message ID. + + + + ```typescript + CometChat.subscribeToThread(0).then( + (response: string) => { + console.log("Subscribed:", response); + }, + (error: CometChat.CometChatException) => { + // code: "INVALID_PARENT_MESSAGE_ID" + console.log(error.code, error.message); + } + ); + ``` + + + ```javascript + CometChat.subscribeToThread(0).then( + (response) => { + console.log("Subscribed:", response); + }, + (error) => { + // code: "INVALID_PARENT_MESSAGE_ID" + console.log(error.code, error.message); + } + ); + ``` + + + +--- + +## Next Steps + + + + Send, receive and fetch messages inside a thread + + + Every listener the SDK exposes, in one place + + + Mention users in messages and filter for your own mentions + + + Filter messages by thread, type, tags and more + + diff --git a/sdk/javascript/threaded-messages.mdx b/sdk/javascript/threaded-messages.mdx index 1d6af168f..c9dd0491b 100644 --- a/sdk/javascript/threaded-messages.mdx +++ b/sdk/javascript/threaded-messages.mdx @@ -168,6 +168,75 @@ messagesRequest.fetchPrevious().then( The `fetchPrevious()` method returns an array of [`BaseMessage`](/sdk/reference/messages#basemessage) objects representing thread replies. +### Hydrate the Parent Message + +A thread reply carries its `parentMessageId`, but not the parent message itself. Use `withParent(true)` when you need the parent hydrated alongside each reply — for example, to render a saved or pinned reply with the message it answers. + + + +```typescript +let messagesRequest: CometChat.MessagesRequest = + new CometChat.MessagesRequestBuilder() + .setLimit(30) + .setParentMessageId(100) + .withParent(true) + .build(); +``` + + + + +```javascript +let messagesRequest = new CometChat.MessagesRequestBuilder() + .setLimit(30) + .setParentMessageId(100) + .withParent(true) + .build(); +``` + + + + + +`withParent(true)` works on any message fetch, including the pinned and saved lists. + +## Subscribe to a Thread + +Users can follow a thread to be notified of future replies, or unfollow it to mute the noise. The server subscribes them automatically when they start a thread, reply in it, or are @-mentioned in it, and they can subscribe explicitly to any parent message. Read the current state off the parent message with `isThreadSubscribed()`. + + + +```typescript +CometChat.subscribeToThread(100).then( + (response: string) => { + console.log("Subscribed to thread:", response); + }, + (error: CometChat.CometChatException) => { + console.log("Failed to subscribe:", error); + } +); +``` + + + + +```javascript +CometChat.subscribeToThread(100).then( + (response) => { + console.log("Subscribed to thread:", response); + }, + (error) => { + console.log("Failed to subscribe:", error); + } +); +``` + + + + + +See [Thread Subscription](/sdk/javascript/thread-subscription) for unsubscribing, reading the subscription state, real-time thread events and fetching the threads a user participates in. + ## Avoid Threaded Messages in User/Group Conversations Use `hideReplies(true)` to exclude threaded messages when fetching messages for a conversation. From 4b3179b0d1d2b7c16cb3afef1c1a8727b6d028c6 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Wed, 2 Sep 2026 13:16:03 +0530 Subject: [PATCH 2/3] fix(pin-save-thread-subscription-js-sdk): correct two claims the SDK no longer honours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages described behaviour that changed when the SDK stopped self-echoing pin/save writes and gained a composite thread cursor. save-message: the Real-time Save Events note said the acting device also receives its own event, de-duplicated by the SDK, and that a consumer could insert into the saved list from the callback alone. The SDK no longer local-emits — the acting device gets nothing and must update from the saveMessage() promise, while the callbacks fire for that user's other devices. Following the old note would have left the saved list un-updated on the device that performed the save. thread-subscription: the paging section said only "call fetchNext() repeatedly", which leads to looping until an empty page. hasMore() is the end-of-list signal — a page can be shorter than the limit because the server's inclusive cursor repeats the boundary row and the SDK filters it out. Adds a while (hasMore()) example in both tabs and states the rule explicitly. --- sdk/javascript/save-message.mdx | 7 ++++--- sdk/javascript/thread-subscription.mdx | 25 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/sdk/javascript/save-message.mdx b/sdk/javascript/save-message.mdx index 2d8e32c94..345af0669 100644 --- a/sdk/javascript/save-message.mdx +++ b/sdk/javascript/save-message.mdx @@ -213,9 +213,10 @@ Save and unsave are **private multi-device** events: they are delivered to the u - The device that performed the save also receives its own event exactly once — - the SDK de-duplicates the write's own echo, so you can insert into your saved - list from the callback alone without double-counting. + These callbacks fire for saves made on your **other** devices, not the one that + performed the save — that device already has the message resolved from + `saveMessage()`. Update your saved list from the promise there, and from these + callbacks everywhere else. ## Save Limit diff --git a/sdk/javascript/thread-subscription.mdx b/sdk/javascript/thread-subscription.mdx index 9189519c1..08b4d7726 100644 --- a/sdk/javascript/thread-subscription.mdx +++ b/sdk/javascript/thread-subscription.mdx @@ -253,7 +253,30 @@ Use `ThreadsRequest` to build a thread inbox. Every returned thread is one the l -Call `fetchNext()` repeatedly on the same object to page through the list. Scope the list to one conversation with `setUid()` or `setGuid()` — the two are mutually exclusive. +Call `fetchNext()` repeatedly on the same object to page through the list, using `hasMore()` as the loop condition. Scope the list to one conversation with `setUid()` or `setGuid()` — the two are mutually exclusive. + + + + ```typescript + const threads: CometChat.MessageThread[] = []; + + while (threadsRequest.hasMore()) { + threads.push(...(await threadsRequest.fetchNext())); + } + ``` + + + ```javascript + const threads = []; + + while (threadsRequest.hasMore()) { + threads.push(...(await threadsRequest.fetchNext())); + } + ``` + + + +`hasMore()` is what tells you the list has ended — page on it rather than on the size of the last result, which can be shorter than the limit without meaning you have reached the end. | Method | Description | | --------------------------- | ------------------------------------------------------------------- | From 992be3c70e6d3f8bceba195100c5f81552f6e831 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Wed, 2 Sep 2026 13:16:47 +0530 Subject: [PATCH 3/3] fix(sdk/javascript): repoint the calling-flow links at the Calls SDK pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three links in the Ongoing Call Listener section pointed at /sdk/javascript/direct-call, /standalone-calling and /default-call, none of which exist — in v4 those pages live under the Calls SDK. Now /calls/v4/javascript/ call-session, /standalone-calling and /ringing, matching the link text already in place. v4 rather than the v5 beta, since OngoingCallListener is the v4 API. Pre-existing 404s, unrelated to the pin/save and thread work in the surrounding commits. --- sdk/javascript/all-real-time-listeners.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/javascript/all-real-time-listeners.mdx b/sdk/javascript/all-real-time-listeners.mdx index d24596d7f..d6749df14 100644 --- a/sdk/javascript/all-real-time-listeners.mdx +++ b/sdk/javascript/all-real-time-listeners.mdx @@ -702,7 +702,7 @@ To remove the listener: The `OngoingCallListener` provides real-time callbacks for active call session events. It is part of the CometChat Calls SDK (not the Chat SDK) and is used with `CallSettingsBuilder.setCallListener()` or `CometChatCalls.addCallEventListener()`. -All three calling flows — [Call Session](/sdk/javascript/direct-call), [Standalone Calling](/sdk/javascript/standalone-calling), and [Ringing](/sdk/javascript/default-call) — use the same `OngoingCallListener`. +All three calling flows — [Call Session](/calls/v4/javascript/call-session), [Standalone Calling](/calls/v4/javascript/standalone-calling), and [Ringing](/calls/v4/javascript/ringing) — use the same `OngoingCallListener`. ### Callbacks