From e6006ef257b4c438cc94a186e7ecc4e719f37100 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Fri, 11 Sep 2026 12:58:55 +0530 Subject: [PATCH 1/4] docs(react,sdk/javascript): align thread subscription and pin/save with other platforms Bring the React UI Kit and JavaScript SDK docs onto the same page as Android, Angular and iOS for thread subscription, pin & save, and pin conversations. React UI Kit: - core-features: add Thread Subscription as a subsection of Threaded Conversations, which previously carried no mention of the feature. Unlike Android and Angular, React has no enable flag, so the section says so explicitly rather than inheriting their "off by default" framing. - core-features: split Pin Conversations into its own section instead of bundling it as a row of the Pin & Save table, matching Android. - core-features and the pin/save guide: pair the .enabled keys with the .limit keys, so each section documents both halves of its app settings. - core-features: add Message Bubble and Message Header to the Pin & Save component table; both behaviours were already documented in the guide but missing from the table. - Retitle the guide's Limits step to App Settings and Limits, and repoint the conversation-pin links at the Pin Conversation anchor. JavaScript SDK: - thread-subscription: add Notification Preferences, covering RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS, and cross-link the QuotedRepliesOptions content in notifications/preferences. Flag that the two enums are distinct so the raw value 4 is not read as interchangeable. - pin-message, save-message, pin-conversation: add Error Handling. Only ERR_ACTION_NOT_ALLOWED was named anywhere across these pages, leaving nothing to branch on in a catch block; the limit-exceeded codes were invisible despite the Pin Limit section advising callers to pre-empt the cap. Codes are read off the kit's own handling, and the sections point at the limit getters as the source for the cap rather than the error text. System pin limit keys are deliberately left out of the UI Kit docs: the kit reads isSystemPinned() for ordering and for suppressing unpin, never the features.ux.*.system.limit settings, and a user cannot system-pin from the UI. They stay documented on the SDK side, where they are reachable. Docs only; no UI Kit or SDK source changes. --- sdk/javascript/pin-conversation.mdx | 22 ++++++++++++ sdk/javascript/pin-message.mdx | 21 +++++++++++ sdk/javascript/save-message.mdx | 22 ++++++++++++ sdk/javascript/thread-subscription.mdx | 24 +++++++++++++ ui-kit/react/components/conversations.mdx | 2 +- ui-kit/react/components/pinned-messages.mdx | 2 +- ui-kit/react/components/saved-messages.mdx | 2 +- ui-kit/react/core-features.mdx | 38 +++++++++++++++++--- ui-kit/react/guide-pin-and-save-messages.mdx | 15 ++++---- 9 files changed, 135 insertions(+), 13 deletions(-) diff --git a/sdk/javascript/pin-conversation.mdx b/sdk/javascript/pin-conversation.mdx index 803dec74d..69477efb5 100644 --- a/sdk/javascript/pin-conversation.mdx +++ b/sdk/javascript/pin-conversation.mdx @@ -233,6 +233,28 @@ These fire both when the logged-in user pins from another device and when an adm Both resolve to `null` when the app settings carry no value. `getSystemPinnedConversationsLimit()` is the separate admin/global cap, enforced independently. +## Error Handling + +`pinConversation()` and `unpinConversation()` reject with a `CometChatException`. Branch on the code rather than the message text: + +| Code | Meaning | +| --- | --- | +| `ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED` | The user's conversation pin cap was reached. | +| `ERR_ACTION_NOT_ALLOWED` | Pin Conversation is not enabled for the app, or the action is otherwise refused. | +| `ERR_PERMISSION_DENIED` | The user is not allowed to pin or unpin this conversation. | + +Pins are per-user, so pinning a conversation never affects anyone else's list. A **system-pinned** conversation cannot be unpinned by a user. + +The code arrives either top-level or nested, so read it defensively: + +```typescript +const code = (error as any)?.code ?? (error as any)?.error?.code; +``` + + +Read the cap from `CometChat.getPinnedConversationsLimit()` — it returns the configured value directly, so you can name the exact number in your own copy without parsing it out of the error text. + + ## Feature Availability diff --git a/sdk/javascript/pin-message.mdx b/sdk/javascript/pin-message.mdx index a22837e7e..3f01acb06 100644 --- a/sdk/javascript/pin-message.mdx +++ b/sdk/javascript/pin-message.mdx @@ -239,6 +239,27 @@ A conversation holds a capped number of pins, configurable per app. Read the cap 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. +## Error Handling + +`pinMessage()` and `unpinMessage()` reject with a `CometChatException`. Branch on the code rather than the message text: + +| Code | Meaning | +| --- | --- | +| `ERR_PERMISSION_DENIED` | The user's role is not allowed to pin or unpin in this conversation. Pin and unpin are gated independently. | +| `ERR_ACTION_NOT_ALLOWED` | Pin Message is not enabled for the app, or the action is otherwise refused. | +| `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` | The conversation's pin cap was reached. | +| `ERR_MESSAGE_NO_ACCESS` | The user cannot act on this message — an access refusal, or a message still held by moderation. | + +The code arrives either top-level or nested, so read it defensively: + +```typescript +const code = (error as any)?.code ?? (error as any)?.error?.code; +``` + + +Read the cap from `CometChat.getPinnedMessagesLimit()` — it returns the configured value directly, so you can name the exact number in your own copy without parsing it out of the error text. + + ## Feature Availability Check whether Pin Message is enabled for your app before showing pin actions. diff --git a/sdk/javascript/save-message.mdx b/sdk/javascript/save-message.mdx index 345af0669..1b29bdad3 100644 --- a/sdk/javascript/save-message.mdx +++ b/sdk/javascript/save-message.mdx @@ -246,6 +246,28 @@ A user may save a capped number of messages across all conversations. Read the c It resolves to `null` when the app settings carry no value — show generic copy in that case rather than guessing a number. +## Error Handling + +`saveMessage()` and `unsaveMessage()` reject with a `CometChatException`. Branch on the code rather than the message text: + +| Code | Meaning | +| --- | --- | +| `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` | The user's save cap was reached. | +| `ERR_ACTION_NOT_ALLOWED` | Save Message is not enabled for the app, or the action is otherwise refused. | +| `ERR_MESSAGE_NO_ACCESS` | The user cannot act on this message — an access refusal, or a message still held by moderation. | + +Saving is per-user, so there is no role gate: a user may always save any message they can see. + +The code arrives either top-level or nested, so read it defensively: + +```typescript +const code = (error as any)?.code ?? (error as any)?.error?.code; +``` + + +Read the cap from `CometChat.getSavedMessagesLimit()` — it returns the configured value directly, so you can name the exact number in your own copy without parsing it out of the error text. + + ## Feature Availability diff --git a/sdk/javascript/thread-subscription.mdx b/sdk/javascript/thread-subscription.mdx index 08b4d7726..47cb5cb4d 100644 --- a/sdk/javascript/thread-subscription.mdx +++ b/sdk/javascript/thread-subscription.mdx @@ -307,6 +307,30 @@ Each row is a `MessageThread`: message's `sentAt` — a thread with no replies has no last reply. +## Notification Preferences + +The notification preference for replies carries a value that pairs with this feature, so a user can be notified only about the threads they follow: `SUBSCRIBE_TO_SUBSCRIBED_THREADS` in the `RepliesOptions` enum. + +| Value | Behavior | +| --- | --- | +| `DONT_SUBSCRIBE` | No notifications for thread replies. | +| `SUBSCRIBE_TO_ALL` | Notifications for all thread replies. | +| `SUBSCRIBE_TO_MENTIONS` | Notifications only for replies that mention the user. | +| `SUBSCRIBE_TO_SUBSCRIBED_THREADS` | Notifications for replies in threads the user is subscribed to. | + +```typescript +const groupPreferences = new CometChatNotifications.GroupPreferences(); +groupPreferences.setRepliesPreference( + CometChatNotifications.RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS +); +``` + + +A **threaded reply** (a message posted into a thread) and a **quoted reply** (a reply to one specific message) are configured through two different enums — `RepliesOptions` and `QuotedRepliesOptions` — and their fourth values differ, so the raw number `4` means something different on each. Pass a `QuotedRepliesOptions` value to `setQuotedRepliesPreference()`, never a `RepliesOptions` one. + + +See [Notification Preferences](/notifications/preferences) for reading and updating a user's preferences, and for the full `QuotedRepliesOptions` list. + ## Error Handling Both `subscribeToThread()` and `unsubscribeFromThread()` reject with a `CometChatException`. The most common client-side failure is an invalid parent message ID. diff --git a/ui-kit/react/components/conversations.mdx b/ui-kit/react/components/conversations.mdx index dc107e01f..6efd7af32 100644 --- a/ui-kit/react/components/conversations.mdx +++ b/ui-kit/react/components/conversations.mdx @@ -364,7 +364,7 @@ These SDK listeners are attached internally. The component updates its state aut When pinning is enabled for your app (the `features.ux.conversations.pinned.enabled` app setting), each row's context menu carries a **Pin / Unpin** action, and pinned chats sort to the top of the list with a pin indicator on the row. This is wired out of the box — no props required. -Pins are personal to each user, and your app can cap how many a user may pin through the `features.ux.conversations.pinned.limit` app setting; when a user reaches the cap, the kit shows a toast naming the limit. To remove the menu action entirely, set [`hidePinConversation`](#hidepinconversation). See [Core Features → Pin & Save](/ui-kit/react/core-features#pin-and-save-messages). +Pins are personal to each user, and your app can cap how many a user may pin through the `features.ux.conversations.pinned.limit` app setting; when a user reaches the cap, the kit shows a toast naming the limit. To remove the menu action entirely, set [`hidePinConversation`](#hidepinconversation). See [Core Features → Pin Conversations](/ui-kit/react/core-features#pin-conversations). A conversation can also be **system-pinned** app-wide (even when empty). System pins always sort above user pins and cannot be unpinned from the UI. diff --git a/ui-kit/react/components/pinned-messages.mdx b/ui-kit/react/components/pinned-messages.mdx index a1a6e85da..5771f5ab6 100644 --- a/ui-kit/react/components/pinned-messages.mdx +++ b/ui-kit/react/components/pinned-messages.mdx @@ -123,7 +123,7 @@ Pinning must be enabled for your app through the `features.ux.messages.pinned.en -**Permissions & limits** — the unpin action is shown to every member; permission is enforced by the **server**, and a rejected unpin surfaces a permission toast (localizable via `action_permission_denied`). If your app caps pins per conversation, hitting the cap shows a toast naming the limit — you don't handle the error yourself. See the [Pin & Save Messages guide](/ui-kit/react/guide-pin-and-save-messages#step-5-limits). +**Permissions & limits** — the unpin action is shown to every member; permission is enforced by the **server**, and a rejected unpin surfaces a permission toast (localizable via `action_permission_denied`). If your app caps pins per conversation, hitting the cap shows a toast naming the limit — you don't handle the error yourself. See the [Pin & Save Messages guide](/ui-kit/react/guide-pin-and-save-messages#step-5-app-settings-and-limits). --- diff --git a/ui-kit/react/components/saved-messages.mdx b/ui-kit/react/components/saved-messages.mdx index ec03a2dad..4fd9ba872 100644 --- a/ui-kit/react/components/saved-messages.mdx +++ b/ui-kit/react/components/saved-messages.mdx @@ -100,7 +100,7 @@ Saving must be enabled for your app through the `features.ux.messages.saved.enab -**Limit** — if your app caps how many messages a user can save, hitting the cap shows a toast naming the limit; you don't handle the error yourself. See the [Pin & Save Messages guide](/ui-kit/react/guide-pin-and-save-messages#step-5-limits). +**Limit** — if your app caps how many messages a user can save, hitting the cap shows a toast naming the limit; you don't handle the error yourself. See the [Pin & Save Messages guide](/ui-kit/react/guide-pin-and-save-messages#step-5-app-settings-and-limits). --- diff --git a/ui-kit/react/core-features.mdx b/ui-kit/react/core-features.mdx index 1273139b7..38c2ec573 100644 --- a/ui-kit/react/core-features.mdx +++ b/ui-kit/react/core-features.mdx @@ -160,6 +160,17 @@ Threads require wiring: capture the parent message from the Message List's `onTh | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Threaded Messages guide](/ui-kit/react/guide-threaded-messages) | The [Threaded Messages guide](/ui-kit/react/guide-threaded-messages) shows how to build a thread panel: `onThreadRepliesClick`, `parentMessageId`, and `CometChatThreadHeader` (which displays the parent message with its reply count). | +### Thread Subscription + +Users can **subscribe** to a thread to keep getting updates about its replies even when they aren't viewing it, and unsubscribe to stop. Subscription is per-user and per-thread, works in both 1:1 and group conversations, and is wired out of the box — there is no flag to turn on. + +| Components | Functionality | +| --- | --- | +| [Message List](/ui-kit/react/components/message-list#hidethreadsubscriptionoption) | Adds a Subscribe / Unsubscribe option to the message options menu. On a reply, the action targets the thread's parent message. | +| [Thread Header](/ui-kit/react/components/thread-header#thread-subscription) | Shows a subscription bell on the thread panel that reflects and toggles the current state. | + +The UI Kit also subscribes a user automatically when they send a message (to that message's own thread), when they post a reply in a thread, and when they are @mentioned in a reply — so people keep hearing about threads they are actually part of. A deliberate unsubscribe is remembered. See the [Threaded Messages guide](/ui-kit/react/guide-threaded-messages#thread-subscription) for the full behaviour, the `hide*` props, and the `useThreadSubscription` hook. + ## Quoted Replies Quoted Replies is a robust feature provided by CometChat that enables users to quickly reply to specific messages by selecting the "Reply" option from a message's action menu. This enhances context, keeps conversations organized, and improves overall chat experience in both 1-1 and group chats. @@ -195,19 +206,38 @@ CometChat lets users **pin** important messages so they're highlighted for every | Components | Functionality | | --- | --- | | [Message List](/ui-kit/react/components/message-list#pin-and-save-options) | Adds Pin, Unpin, Save, and Unsave to the message options menu when the features are enabled. | +| [Message Bubble](/ui-kit/react/components/message-bubble#pinned-and-saved-indicators) | Marks pinned and saved messages with an indicator on the bubble. | +| [Message Header](/ui-kit/react/components/message-header) | Provides the "Pinned messages" entry point in its overflow menu. | | [Pinned Messages](/ui-kit/react/components/pinned-messages) | A panel of the messages pinned in a conversation, opened from the [Message Header](/ui-kit/react/components/message-header). | | [Saved Messages](/ui-kit/react/components/saved-messages) | A personal screen of the current user's saved messages across all conversations. | -| [Conversations](/ui-kit/react/components/conversations#hidepinconversation) | Lets users pin a whole conversation to the top of their list. | -Your app can also cap how many items a user may pin or save through three app settings: +Each feature is switched on per app, and each carries a cap on how many items a user may pin or save: -| App setting | Caps | +| App setting | Controls | | --- | --- | +| features.ux.messages.pinned.enabled | Whether the pin options and panel render at all | | features.ux.messages.pinned.limit | Pinned messages per conversation | +| features.ux.messages.saved.enabled | Whether the save options and screen render at all | | features.ux.messages.saved.limit | Saved messages per user | + +The UI Kit reads these settings at login, so no wiring is needed to show, hide, or enforce them. When a user reaches a cap, the kit shows a toast that names the exact limit — no extra handling required. For a full walkthrough, see the [Pin & Save Messages guide](/ui-kit/react/guide-pin-and-save-messages). + +## Pin Conversations + +Keep the chats that matter at the top. Users pin a conversation from the row's context menu; pinned conversations show a pin indicator and sort above the rest of the list. Pins are personal to each user. + +| Components | Functionality | +| --- | --- | +| [Conversations](/ui-kit/react/components/conversations#pin-conversation) | Provides the Pin / Unpin conversation option, the row indicator, and pinned-first ordering. | + +Conversation pinning is switched on per app, and carries its own cap: + +| App setting | Controls | +| --- | --- | +| features.ux.conversations.pinned.enabled | Whether the pin/unpin conversation option renders | | features.ux.conversations.pinned.limit | Pinned conversations per user | -The UI Kit reads these settings at login. When a user reaches a cap, the kit shows a toast that names the exact limit — no extra handling required. For a full walkthrough, see the [Pin & Save Messages guide](/ui-kit/react/guide-pin-and-save-messages). +A conversation can also be **system-pinned** app-wide by your app. System pins always sort above user pins and cannot be unpinned from the UI. See [Conversations → Pin Conversation](/ui-kit/react/components/conversations#pin-conversation). ## Group Chat diff --git a/ui-kit/react/guide-pin-and-save-messages.mdx b/ui-kit/react/guide-pin-and-save-messages.mdx index 7223051c4..d4301f255 100644 --- a/ui-kit/react/guide-pin-and-save-messages.mdx +++ b/ui-kit/react/guide-pin-and-save-messages.mdx @@ -141,15 +141,18 @@ Pinned and saved messages render an indicator on the bubble in the main message allow="clipboard-write" > -## Step 5: Limits +## Step 5: App Settings and Limits -Your app can cap how many messages may be pinned or saved. These caps are configured as app settings in the dashboard: +Each feature is switched on per app, and each carries a cap on how many items a user may pin or save. Both are configured as app settings in the dashboard: -| Setting | Caps | +| Setting | Controls | | --- | --- | +| features.ux.messages.pinned.enabled | Whether the pin options and panel render at all | | features.ux.messages.pinned.limit | Pins per conversation | +| features.ux.messages.saved.enabled | Whether the save options and screen render at all | | features.ux.messages.saved.limit | Saves per user | -| features.ux.conversations.pinned.limit | Pinned conversations per user (see [Conversations](/ui-kit/react/components/conversations#hidepinconversation)) | +| features.ux.conversations.pinned.enabled | Whether the pin/unpin conversation option renders | +| features.ux.conversations.pinned.limit | Pinned conversations per user (see [Conversations](/ui-kit/react/components/conversations#pin-conversation)) | When a user hits a cap, the UI Kit shows a toast explaining the limit — you don't need to handle the error yourself. The kit reads these settings at login so the toast can name the exact cap. @@ -199,7 +202,7 @@ function PinButton({ message }: { message: CometChat.BaseMessage }) { } ``` -Permission and the per-app caps are enforced by the **server**, so wrap the calls in `try/catch` and surface a message on rejection — see [Limits](#step-5-limits) and the permission behavior in [Step 1](#step-1-the-message-options). Publishing the `ui:` events above is what keeps the message list and the Pinned/Saved panels in step with your custom action. For the full event list, see the [Event System](/ui-kit/react/event-system#pin-and-save). +Permission and the per-app caps are enforced by the **server**, so wrap the calls in `try/catch` and surface a message on rejection — see [App Settings and Limits](#step-5-app-settings-and-limits) and the permission behavior in [Step 1](#step-1-the-message-options). Publishing the `ui:` events above is what keeps the message list and the Pinned/Saved panels in step with your custom action. For the full event list, see the [Event System](/ui-kit/react/event-system#pin-and-save). ## Complete Example @@ -299,4 +302,4 @@ export default App; - [Pinned Messages](/ui-kit/react/components/pinned-messages) — configure the pinned-messages panel - [Saved Messages](/ui-kit/react/components/saved-messages) — configure the saved-messages screen - [Message List](/ui-kit/react/components/message-list) — toggle the pin/save message options -- [Conversations](/ui-kit/react/components/conversations#hidepinconversation) — let users pin whole conversations +- [Conversations](/ui-kit/react/components/conversations#pin-conversation) — let users pin whole conversations From f33824688381847ba4d9491282750e3941f9f036 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Fri, 11 Sep 2026 18:50:31 +0530 Subject: [PATCH 2/4] docs(react,sdk/javascript): correct pin/save error codes against the live server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #518. The error-handling tables added in the previous commit were written from the SDK's JSDoc and constants; several rows were wrong. Every code below was verified by calling the endpoints against the e2e app and recording what came back, rather than by reading source. Corrected: - pin-message: a member's pin is rejected with ERR_PERMISSION_DENIED (HTTP 403), not ERR_ACTION_NOT_ALLOWED. The page previously said both, in two places. The stale claim came from the SDK JSDoc on pinMessage and from PIN_SAVE_ERROR_CODES, which both name ERR_ACTION_NOT_ALLOWED as the SBAC rejection; those are wrong at source and need a separate fix. - Drop "ERR_ACTION_NOT_ALLOWED means the feature is not enabled" from all three tables. Nothing supports it, and the server was never observed returning it. - pin-conversation: drop the speculative ERR_PERMISSION_DENIED row. Conversation pinning is per-user with no role gate. A missing peer returns ERR_UID_NOT_FOUND or ERR_GUID_NOT_FOUND (HTTP 404). Added, each observed: - ERR_MESSAGE_ID_NOT_FOUND (404) on pin and save. - ERR_MESSAGE_NO_ACCESS (403) when the caller is not a participant. - HTTP status alongside every row, including the limit codes (ERR_PINNED_MESSAGES_LIMIT_EXCEEDED, 400, message carrying "limit of N"). - Unpinning a conversation that was never pinned succeeds rather than erroring. Also from the review: - Use plain error.code in TypeScript/JavaScript tabs instead of a defensive top-level-or-nested read. The SDK unwraps the REST shape before rejecting, so the code is always top-level; the nested pattern belongs to the UI Kit's own helper, not to this API. - The limit getters resolve to the configured cap or null, so say that rather than "returns the configured value directly". - thread-subscription: the preferences example now calls updatePreferences() — without it the snippet changed nothing — and sets one-on-one alongside group, since threads are not group-only. - core-features and the threaded-messages guide: auto-subscribe is performed by the server, so it applies to any app on the SDK. The kit only reflects it. - core-features: pinned messages are capped per conversation, not per user. - save-message: a participant can save a message they have access to, up to the cap, which no longer contradicts the ERR_MESSAGE_NO_ACCESS row above it. Two cases stay as they were, both unverifiable from here: whether unpinning a system-pinned conversation returns ERR_SYSTEM_PINNED_CONVERSATION (system pins cannot be created through the admin API, which requires onBehalfOf and so makes an ordinary user pin), and which code a disabled feature returns (needs an app-settings toggle on the shared e2e app). Both keep the SDK's current wording rather than adopting an unconfirmed code. Docs only; no UI Kit or SDK source changes. --- sdk/javascript/pin-conversation.mdx | 43 ++++++++++++++---- sdk/javascript/pin-message.mdx | 45 ++++++++++++++----- sdk/javascript/save-message.mdx | 43 +++++++++++++----- sdk/javascript/thread-subscription.mdx | 56 +++++++++++++++++++++--- ui-kit/react/core-features.mdx | 4 +- ui-kit/react/guide-threaded-messages.mdx | 2 +- 6 files changed, 154 insertions(+), 39 deletions(-) diff --git a/sdk/javascript/pin-conversation.mdx b/sdk/javascript/pin-conversation.mdx index 69477efb5..78c280666 100644 --- a/sdk/javascript/pin-conversation.mdx +++ b/sdk/javascript/pin-conversation.mdx @@ -239,20 +239,45 @@ Both resolve to `null` when the app settings carry no value. `getSystemPinnedCon | Code | Meaning | | --- | --- | -| `ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED` | The user's conversation pin cap was reached. | -| `ERR_ACTION_NOT_ALLOWED` | Pin Conversation is not enabled for the app, or the action is otherwise refused. | -| `ERR_PERMISSION_DENIED` | The user is not allowed to pin or unpin this conversation. | +| `ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED` | The user's conversation pin cap was reached (HTTP 400). | +| `ERR_UID_NOT_FOUND` / `ERR_GUID_NOT_FOUND` | The peer named by `conversationWith` does not exist (HTTP 404). | +| `ERR_ACTION_NOT_ALLOWED` | The action was refused — including an attempt to unpin a **system-pinned** conversation, which a user may never unpin. | -Pins are per-user, so pinning a conversation never affects anyone else's list. A **system-pinned** conversation cannot be unpinned by a user. +Pins are per-user, so pinning a conversation never affects anyone else's list. Check `conversation.isSystemPinned()` and hide the unpin control rather than letting the call fail. -The code arrives either top-level or nested, so read it defensively: +Both calls are idempotent: unpinning a conversation that was never pinned succeeds rather than erroring. -```typescript -const code = (error as any)?.code ?? (error as any)?.error?.code; -``` + + + ```typescript + CometChat.pinConversation("uid", "user").then( + (response: CometChat.BaseMessage) => { + console.log("Done:", response); + }, + (error: CometChat.CometChatException) => { + // e.g. code: "ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED" + console.log(error.code, error.message); + } + ); + ``` + + + ```javascript + CometChat.pinConversation("uid", "user").then( + (response) => { + console.log("Done:", response); + }, + (error) => { + // e.g. code: "ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED" + console.log(error.code, error.message); + } + ); + ``` + + -Read the cap from `CometChat.getPinnedConversationsLimit()` — it returns the configured value directly, so you can name the exact number in your own copy without parsing it out of the error text. +Read the cap from `CometChat.getPinnedConversationsLimit()`, which resolves to the configured cap, or `null` when unset — so you can name the exact number in your own copy without parsing it out of the error text. ## Feature Availability diff --git a/sdk/javascript/pin-message.mdx b/sdk/javascript/pin-message.mdx index 3f01acb06..27550926e 100644 --- a/sdk/javascript/pin-message.mdx +++ b/sdk/javascript/pin-message.mdx @@ -8,7 +8,7 @@ Pinning highlights an important message in a conversation. A pin is **conversati 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`. + rejected with `ERR_PERMISSION_DENIED`. ## Pin a Message @@ -245,19 +245,42 @@ Both resolve to `null` when the app settings carry no value — show generic cop | Code | Meaning | | --- | --- | -| `ERR_PERMISSION_DENIED` | The user's role is not allowed to pin or unpin in this conversation. Pin and unpin are gated independently. | -| `ERR_ACTION_NOT_ALLOWED` | Pin Message is not enabled for the app, or the action is otherwise refused. | -| `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` | The conversation's pin cap was reached. | -| `ERR_MESSAGE_NO_ACCESS` | The user cannot act on this message — an access refusal, or a message still held by moderation. | +| `ERR_PERMISSION_DENIED` | The acting user's role may not pin or unpin here (HTTP 403). Pin and unpin are gated independently. | +| `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` | The conversation's pin cap was reached (HTTP 400). | +| `ERR_MESSAGE_ID_NOT_FOUND` | No message with that id — it never existed, or it was deleted (HTTP 404). | +| `ERR_MESSAGE_NO_ACCESS` | The user has no access to that message — for example they are not a participant in its conversation (HTTP 403). | -The code arrives either top-level or nested, so read it defensively: - -```typescript -const code = (error as any)?.code ?? (error as any)?.error?.code; -``` + + + ```typescript + CometChat.pinMessage("1").then( + (response: CometChat.BaseMessage) => { + console.log("Done:", response); + }, + (error: CometChat.CometChatException) => { + // e.g. code: "ERR_PINNED_MESSAGES_LIMIT_EXCEEDED" + console.log(error.code, error.message); + } + ); + ``` + + + ```javascript + CometChat.pinMessage("1").then( + (response) => { + console.log("Done:", response); + }, + (error) => { + // e.g. code: "ERR_PINNED_MESSAGES_LIMIT_EXCEEDED" + console.log(error.code, error.message); + } + ); + ``` + + -Read the cap from `CometChat.getPinnedMessagesLimit()` — it returns the configured value directly, so you can name the exact number in your own copy without parsing it out of the error text. +Read the cap from `CometChat.getPinnedMessagesLimit()`, which resolves to the configured cap, or `null` when unset — so you can name the exact number in your own copy without parsing it out of the error text. ## Feature Availability diff --git a/sdk/javascript/save-message.mdx b/sdk/javascript/save-message.mdx index 1b29bdad3..18045552b 100644 --- a/sdk/javascript/save-message.mdx +++ b/sdk/javascript/save-message.mdx @@ -252,20 +252,43 @@ It resolves to `null` when the app settings carry no value — show generic copy | Code | Meaning | | --- | --- | -| `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` | The user's save cap was reached. | -| `ERR_ACTION_NOT_ALLOWED` | Save Message is not enabled for the app, or the action is otherwise refused. | -| `ERR_MESSAGE_NO_ACCESS` | The user cannot act on this message — an access refusal, or a message still held by moderation. | +| `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` | The user's save cap was reached (HTTP 400). | +| `ERR_MESSAGE_ID_NOT_FOUND` | No message with that id — it never existed, or it was deleted (HTTP 404). | +| `ERR_MESSAGE_NO_ACCESS` | The user has no access to that message — for example they are not a participant in its conversation (HTTP 403). | -Saving is per-user, so there is no role gate: a user may always save any message they can see. +Saving is per-user, so there is no role gate: any participant can save a message they have access to, up to the cap. -The code arrives either top-level or nested, so read it defensively: - -```typescript -const code = (error as any)?.code ?? (error as any)?.error?.code; -``` + + + ```typescript + CometChat.saveMessage("1").then( + (response: CometChat.BaseMessage) => { + console.log("Done:", response); + }, + (error: CometChat.CometChatException) => { + // e.g. code: "ERR_SAVED_MESSAGES_LIMIT_EXCEEDED" + console.log(error.code, error.message); + } + ); + ``` + + + ```javascript + CometChat.saveMessage("1").then( + (response) => { + console.log("Done:", response); + }, + (error) => { + // e.g. code: "ERR_SAVED_MESSAGES_LIMIT_EXCEEDED" + console.log(error.code, error.message); + } + ); + ``` + + -Read the cap from `CometChat.getSavedMessagesLimit()` — it returns the configured value directly, so you can name the exact number in your own copy without parsing it out of the error text. +Read the cap from `CometChat.getSavedMessagesLimit()`, which resolves to the configured cap, or `null` when unset — so you can name the exact number in your own copy without parsing it out of the error text. ## Feature Availability diff --git a/sdk/javascript/thread-subscription.mdx b/sdk/javascript/thread-subscription.mdx index 47cb5cb4d..33226476f 100644 --- a/sdk/javascript/thread-subscription.mdx +++ b/sdk/javascript/thread-subscription.mdx @@ -318,12 +318,56 @@ The notification preference for replies carries a value that pairs with this fea | `SUBSCRIBE_TO_MENTIONS` | Notifications only for replies that mention the user. | | `SUBSCRIBE_TO_SUBSCRIBED_THREADS` | Notifications for replies in threads the user is subscribed to. | -```typescript -const groupPreferences = new CometChatNotifications.GroupPreferences(); -groupPreferences.setRepliesPreference( - CometChatNotifications.RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS -); -``` +Threads exist in one-on-one conversations as well as groups, so set it on whichever preferences you are updating: + + + + ```typescript + const updatedPreferences = new CometChatNotifications.NotificationPreferences(); + + const groupPreferences = new CometChatNotifications.GroupPreferences(); + groupPreferences.setRepliesPreference( + CometChatNotifications.RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS + ); + updatedPreferences.setGroupPreferences(groupPreferences); + + const oneOnOnePreferences = new CometChatNotifications.OneOnOnePreferences(); + oneOnOnePreferences.setRepliesPreference( + CometChatNotifications.RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS + ); + updatedPreferences.setOneOnOnePreferences(oneOnOnePreferences); + + const preferences = await CometChatNotifications.updatePreferences( + updatedPreferences + ); + ``` + + + ```javascript + const updatedPreferences = new CometChatNotifications.NotificationPreferences(); + + const groupPreferences = new CometChatNotifications.GroupPreferences(); + groupPreferences.setRepliesPreference( + CometChatNotifications.RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS + ); + updatedPreferences.setGroupPreferences(groupPreferences); + + const oneOnOnePreferences = new CometChatNotifications.OneOnOnePreferences(); + oneOnOnePreferences.setRepliesPreference( + CometChatNotifications.RepliesOptions.SUBSCRIBE_TO_SUBSCRIBED_THREADS + ); + updatedPreferences.setOneOnOnePreferences(oneOnOnePreferences); + + const preferences = await CometChatNotifications.updatePreferences( + updatedPreferences + ); + ``` + + + + +`updatePreferences()` merges what you set, so sending only the fields you changed is enough. + A **threaded reply** (a message posted into a thread) and a **quoted reply** (a reply to one specific message) are configured through two different enums — `RepliesOptions` and `QuotedRepliesOptions` — and their fourth values differ, so the raw number `4` means something different on each. Pass a `QuotedRepliesOptions` value to `setQuotedRepliesPreference()`, never a `RepliesOptions` one. diff --git a/ui-kit/react/core-features.mdx b/ui-kit/react/core-features.mdx index 38c2ec573..98f123873 100644 --- a/ui-kit/react/core-features.mdx +++ b/ui-kit/react/core-features.mdx @@ -169,7 +169,7 @@ Users can **subscribe** to a thread to keep getting updates about its replies ev | [Message List](/ui-kit/react/components/message-list#hidethreadsubscriptionoption) | Adds a Subscribe / Unsubscribe option to the message options menu. On a reply, the action targets the thread's parent message. | | [Thread Header](/ui-kit/react/components/thread-header#thread-subscription) | Shows a subscription bell on the thread panel that reflects and toggles the current state. | -The UI Kit also subscribes a user automatically when they send a message (to that message's own thread), when they post a reply in a thread, and when they are @mentioned in a reply — so people keep hearing about threads they are actually part of. A deliberate unsubscribe is remembered. See the [Threaded Messages guide](/ui-kit/react/guide-threaded-messages#thread-subscription) for the full behaviour, the `hide*` props, and the `useThreadSubscription` hook. +Subscriptions are also created automatically by the **server**, so they apply to any app on the SDK, not just the UI Kit: sending a message subscribes you to that message's own thread, posting a reply subscribes you to that thread, and being @mentioned in a reply subscribes you. The kit reflects these the moment they happen. A deliberate unsubscribe is remembered. See the [Threaded Messages guide](/ui-kit/react/guide-threaded-messages#thread-subscription) for the full behaviour, the `hide*` props, and the `useThreadSubscription` hook. ## Quoted Replies @@ -211,7 +211,7 @@ CometChat lets users **pin** important messages so they're highlighted for every | [Pinned Messages](/ui-kit/react/components/pinned-messages) | A panel of the messages pinned in a conversation, opened from the [Message Header](/ui-kit/react/components/message-header). | | [Saved Messages](/ui-kit/react/components/saved-messages) | A personal screen of the current user's saved messages across all conversations. | -Each feature is switched on per app, and each carries a cap on how many items a user may pin or save: +Each feature is switched on per app, and each carries its own cap: | App setting | Controls | | --- | --- | diff --git a/ui-kit/react/guide-threaded-messages.mdx b/ui-kit/react/guide-threaded-messages.mdx index 66d88e8f5..20bd6af10 100644 --- a/ui-kit/react/guide-threaded-messages.mdx +++ b/ui-kit/react/guide-threaded-messages.mdx @@ -222,7 +222,7 @@ Both reflect the current subscription state, flip it optimistically on click, an ### Automatic subscription -Beyond the manual bell and menu option, the UI Kit subscribes a user to a thread automatically in a few cases, so people keep getting updates on threads they're actually part of — without having to remember to follow them: +Beyond the manual bell and menu option, the **server** subscribes a user to a thread automatically in a few cases, so people keep getting updates on threads they're actually part of — without having to remember to follow them. These happen server-side, so they apply to any app on the SDK; the UI Kit reflects them as soon as they occur: - **Sending a message subscribes you to its thread.** When you send a message, you're subscribed to the thread on that message — so you keep hearing about replies to it — and sending a reply inside a thread subscribes you to that thread as well. This applies to every message type — text, media, stickers, polls, collaborative documents, and custom messages. - **Being @mentioned in a reply subscribes you.** If someone @mentions you in a threaded reply, you're subscribed — whether the mention is added on a fresh reply or introduced (or preserved) by an edit. From 960f12a58bd086a04f1a0d65ddd49cc194b136f7 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Tue, 22 Sep 2026 16:22:58 +0530 Subject: [PATCH 3/4] enhancement(custom-formatter-react-v7): improve the custom color formatter guide with a complete end-to-end example. - Steps: how it works, then token and display, send, typing in color, coloring a selection, the button, composer setup, and a table of every surface with code. - Complete code: both files at the end in collapsible sections. - Token warning: a note that other platforms must use the same token. --- ui-kit/react/guide-custom-text-formatter.mdx | 1058 ++++++++++++++++-- 1 file changed, 973 insertions(+), 85 deletions(-) diff --git a/ui-kit/react/guide-custom-text-formatter.mdx b/ui-kit/react/guide-custom-text-formatter.mdx index c30c20db1..69ac375c1 100644 --- a/ui-kit/react/guide-custom-text-formatter.mdx +++ b/ui-kit/react/guide-custom-text-formatter.mdx @@ -1,159 +1,1047 @@ --- title: "Custom Text Formatter" sidebarTitle: "Custom Text Formatter" -description: "Build a minimal color formatter, bind it to a toolbar button in the composer, and render the result in read-only message bubbles." +description: "Build a text color formatter: color text live in the composer, send it as a token, and render it on every surface that shows the message." --- ## Goal -By the end of this guide you will have a **color formatter**: a toolbar button in the composer that wraps the selected text in a color marker, and a formatter that renders that marker as colored text everywhere the message appears — in the composer, in the message list, conversation subtitle, pinned and saved messages. +By the end of this guide you will have a **text color formatter**, working end to end: -A text formatter has two jobs, and this guide covers both: +- **Composer:** a toolbar button picks a color. Text you type next is colored as you type, and selected text can be colored or cleared. +- **Send:** colored text is stored on the message as a plain-text token, `{color:#e5484d}text{/color}`. +- **Display:** the token renders as colored text in message bubbles, the conversation list, thread headers, pinned and saved messages, search, reply and edit previews, and copied text is plain. -1. **Rendering** — turn a marker in the raw message text into styled HTML wherever the message is displayed (`format()`). -2. **Authoring** — give users a way to produce that marker. Here, a button in the composer's `toolbarTrailingView` wraps the current selection. +Each step below explains one part of the formatter with an excerpt. The [complete code](#complete-code) at the end is ready to drop into your app. -For the full formatter reference — the built-in Markdown, Mentions, and URL formatters and the complete `CometChatTextFormatter` API — see [Text Formatters](/ui-kit/react/plugins/text-formatters). This guide is the minimal, task-focused version. +For the built-in Markdown, Mentions, and URL formatters and the full `CometChatTextFormatter` API, see [Text Formatters](/ui-kit/react/plugins/text-formatters). ## Prerequisites - Completed the [Integration Guide](/ui-kit/react/integration-react) - A chat screen using `CometChatMessageList` and `CometChatMessageComposer` +- React 18 or later (the toolbar button uses `useSyncExternalStore`) -## Step 1: The Formatter +## How It Works -Extend `CometChatTextFormatter`. The one method that matters for rendering is `format()`: it receives the raw message text and returns HTML. Our marker is `{color=VALUE}...{/color}`, and we turn it into a colored ``. +``` +Composer world + │ send: getOriginalText() + ▼ +Message text {color:#e5484d}world{/color} + │ display: format() → customLogicToFormatText() + ▼ +Every surface world +``` + +A formatter has three jobs, and `ColorFormatter` does all three: + +| Job | Where it runs | Methods | +|:----|:--------------|:--------| +| **Display**: token → HTML | Every surface the formatter is registered on | `customLogicToFormatText` | +| **Send**: composer HTML → token | The composer, on send and on paste | `getOriginalText` | +| **Live input**: color text as you type | The composer (rich text editor only) | `onKeyUp`, `formatText`, `initializeComposerTracking` | + +The composer gives each formatter in its `textFormatters` a reference to the editor element (`inputElementReference`), forwards every `keyup` to `onKeyUp`, and supplies caret helpers (`getCaretPosition`, `setCaretPosition`) and `reRender()`, which syncs the composer after the formatter changes the editor's DOM. + +## Step 1: Token and Display -_File: src/formatters/ColorFormatter.ts_ +The token is plain text, so it survives storage and delivery unchanged. `customLogicToFormatText` turns it into a colored span. You don't need to override `format()`: the base class calls `customLogicToFormatText` from it, so every display surface uses this method. + +_File: src/formatters/ColorFormatter.ts (excerpt)_ ```typescript -import { CometChatTextFormatter } from "@cometchat/chat-uikit-react"; +import { CometChatTextFormatter } from '@cometchat/chat-uikit-react'; + +/** Class on every colored span, in the composer and in rendered messages. */ +const COLOR_CLASS = 'cc-color'; +/** Accepted color values: 3- to 6-digit hex. */ +const HEX = '#[0-9a-fA-F]{3,6}'; +/** Stored token: `{color:#e5484d}text{/color}`. */ +const TOKEN_REGEX = new RegExp(`\\{color:(${HEX})\\}([\\s\\S]*?)\\{/color\\}`, 'g'); -/** Matches {color=#e5484d}text{/color} — a CSS color, then the wrapped text. */ -const COLOR_REGEX = /\{color=(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\}([\s\S]*?)\{\/color\}/g; +// … export class ColorFormatter extends CometChatTextFormatter { - readonly id = "color-formatter"; - override priority = 20; // after markdown (10), before mentions/URLs + readonly id = 'color'; + override priority = 60; - getRegex(): RegExp { - return COLOR_REGEX; - } + // … - format(text: string): string { - this.originalText = text ?? ""; - this.formattedText = this.originalText.replace( - this.getRegex(), - (_match, color: string, inner: string) => - `${inner}`, - ); - return this.formattedText; + /** + * Token → HTML. Used by every display surface (the base `format()` delegates here) and to restore + * colors after a paste. + */ + override customLogicToFormatText(text: string): string { + return text.replace(TOKEN_REGEX, `$2`); } } ``` -`format()` must store `originalText`, set `formattedText`, and return the formatted string — the pipeline relies on those fields. Keep it fast: it runs on every text message render. +Formatters run in `priority` order, lowest first. Markdown runs at 10, mentions at 20, URLs at 100. At 60, the color token is replaced after markdown and mentions have already been rendered inside it. -## Step 2: The Toolbar Button +## Step 2: Serialize on Send -The composer's `toolbarTrailingView` renders a node at the end of the rich-text toolbar. Put a button there that wraps the user's current selection in the color marker. +On send, the composer passes the editor's HTML through each formatter's `getOriginalText` before converting it to markdown. `ColorFormatter` replaces each colored span with the token. -_File: src/components/ColorButton.tsx_ +It walks the DOM instead of using a regex because a colored run can contain other elements. If you insert a mention while typing in color, the mention's own `` would end a non-greedy `…` match early and leave a broken token in the message. -```tsx -export function ColorButton({ color = "#e5484d" }: { color?: string }) { - function wrapSelection() { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; - - const range = selection.getRangeAt(0); - const selected = range.toString(); - range.deleteContents(); - range.insertNode( - document.createTextNode(`{color=${color}}${selected}{/color}`), +```typescript +/** Composer HTML → token. Used on send and before a paste is sanitized. */ +override getOriginalText(inputText?: string): string { + if (inputText === undefined) return super.getOriginalText(); + if (!inputText.includes(COLOR_CLASS)) return inputText; + // Walk the DOM rather than using a regex: a colored run can contain another element (e.g. a + // mention), and a non-greedy `…` regex would stop at that element's closing tag. + const doc = new DOMParser().parseFromString(inputText, 'text/html'); + doc.querySelectorAll(`span.${COLOR_CLASS}`).forEach(el => { + const parent = el.parentNode; + if (!parent) return; + const color = this.colorOf(el as HTMLElement); + // Replace the span with `{color:#hex}` + its children + `{/color}`. + if (color) parent.insertBefore(doc.createTextNode(`{color:${color}}`), el); + while (el.firstChild) parent.insertBefore(el.firstChild, el); + if (color) parent.insertBefore(doc.createTextNode('{/color}'), el); + parent.removeChild(el); + }); + return doc.body.innerHTML; +} +``` + + +The composer also runs `getOriginalText` and then `customLogicToFormatText` around its paste sanitizer, so colored text copied from one message and pasted into the composer keeps its color. You get this for free. + + +## Step 3: Color Text as You Type + +The formatter works like a highlighter pen. `setActiveColor` turns the pen on (or off, with `null`), and `formatText` wraps newly typed text in a span of the pen's color after each keystroke. + +```typescript +/** Pen color applied to newly typed text; `null` when the pen is off. */ +private activeColor: string | null = null; +/** Set when the pen is turned off inside a colored run: the next typed char is moved out of it. */ +private breakoutPending = false; + +// … + +/** Set the pen color for newly typed text, or `null` to turn the pen off. */ +setActiveColor(color: string | null): void { + this.activeColor = color; + if (color === null && this.caretColorSpan()) this.breakoutPending = true; + this.notify(); +} + +override onKeyUp(event: KeyboardEvent): void { + // Skip IME composition; the committed text arrives with a later keyup. + if (event.isComposing || event.keyCode === 229) return; + this.formatText(); + this.lastCaret = this.getCaretPosition(); +} + +/** Color the text typed since the last keystroke according to the pen. */ +override formatText(): void { + const root = this.inputElementReference; + if (!root) return; + const sel = this.selection(); + if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return; + const range = sel.getRangeAt(0); + const node = range.startContainer; + const offset = range.startOffset; + if (!root.contains(node) || node.nodeType !== Node.TEXT_NODE || offset === 0) { + if (this.activeColor === null) this.breakoutPending = false; + return; + } + if (this.isProtected(node, root)) return; + + const text = node as Text; + const span = this.closestColorSpan(text); + const caret = this.getCaretPosition(); + + // Pen was turned off inside a colored run: move the typed char out, uncolored. + if (this.breakoutPending) { + this.breakoutPending = false; + if (span) { + this.breakOutCharBefore(text, offset, span, null); + this.setCaretPosition(caret); + this.reRender(); + } + return; + } + + if (this.activeColor === null) return; + // The browser already typed into a run of the pen's color. + if (span && this.colorOf(span) === this.activeColor) return; + + if (span) { + // Typed inside a run of a different color: move the char out and wrap it in the pen color. + this.breakOutCharBefore(text, offset, span, this.activeColor); + } else { + // Color everything typed since the last keystroke. A delta outside [1, min(offset, cap)] + // means the caret jumped, so color just the last char. + const delta = caret - this.lastCaret; + const runLen = delta >= 1 && delta <= MAX_TYPED_RUN && delta <= offset ? delta : 1; + this.wrapRunBefore(text, offset, runLen, this.activeColor); + } + this.setCaretPosition(caret); + this.reRender(); +} +``` + +A few details keep typing correct: + +- **Only newly typed text is colored.** `lastCaret` records the caret after each keystroke or click, so `formatText` colors exactly the characters typed since, including a quick burst typed before a `keyup` arrived, and never text that was already there. +- **Changing color mid-word.** The browser keeps inserting into the span the caret is in. When the pen's color differs from that span, or the pen was turned off inside it (`breakoutPending`), the typed character is moved out of the span. Text after the caret keeps its color. +- **Protected content.** Mentions and other non-editable nodes, code, and links are never colored. Pass `protectedSelectors` to the constructor to protect more, such as another formatter's spans. +- **IME input** is skipped until the composition is committed. + +`initializeComposerTracking` runs once the composer has assigned the editor. It keeps `lastCaret` in sync on mouse clicks and turns the pen off when the input is cleared, for example after a message is sent. + +```typescript +/** Called by the composer once `inputElementReference` is assigned. */ +override initializeComposerTracking(): void { + const root = this.inputElementReference; + if (!root) return; + this.domObserver?.disconnect(); + + // Clicks move the caret without a keyup, so sync `lastCaret` on mouseup too. + if (this.trackedRoot) this.trackedRoot.removeEventListener('mouseup', this.caretSync); + this.trackedRoot = root; + root.addEventListener('mouseup', this.caretSync); + + // Turn the pen off when the input goes from non-empty to empty (message sent or cleared). + this.domObserver = new MutationObserver(() => { + const empty = (root.textContent ?? '').trim() === ''; + if (!empty) { + this.wasNonEmpty = true; + return; + } + if (this.wasNonEmpty) { + this.wasNonEmpty = false; + this.lastCaret = 0; + if (this.activeColor !== null || this.breakoutPending) { + this.activeColor = null; + this.breakoutPending = false; + this.notify(); + } + } + }); + this.domObserver.observe(root, { childList: true, subtree: true, characterData: true }); +} +``` + +## Step 4: Color a Selection + +`applyColorToSelection` colors the selected text, replacing any color it already has. `clearColorInSelection` removes it. Both split existing runs so text outside the selection keeps its color, and both skip protected content. + +```typescript +/** Apply `color` to the current selection, replacing any color already there. */ +applyColorToSelection(color: string): void { + const root = this.inputElementReference; + if (!root) return; + const sel = this.selection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; + const spans: HTMLElement[] = []; + for (const { node, start, end } of this.collectSlices(sel.getRangeAt(0), root)) { + const existing = this.closestColorSpan(node); + spans.push( + existing + ? this.recolorSlice(existing, node, start, end, color) + : this.wrapSlice(node, start, end, color) ); - selection.removeAllRanges(); } + if (spans.length === 0) return; + this.mergeRun(spans); + this.reselect(spans); + this.reRender(); +} +``` - return ( - - ); +For the button in the next step, the formatter also exposes `onColorChange` and `getCaretColor`, shaped for React's `useSyncExternalStore`, so the button can show the color at the caret. + +## Step 5: The Toolbar Button + +The composer renders `toolbarTrailingView` at the end of the rich-text toolbar. `ColorPickerButton` puts two controls there: + +- **A** opens the native color picker. Picking a color colors the current selection (if any) and turns the pen on. +- **⌫** clears color from the selection, or turns the pen off when nothing is selected. + +_File: src/formatters/ColorPickerButton.tsx (excerpt)_ + +```tsx +export function ColorPickerButton({ formatter }: ColorPickerButtonProps) { + const [lastColor, setLastColor] = useState(DEFAULT_COLOR); + const caretColor = useSyncExternalStore(formatter.onColorChange, formatter.getCaretColor); + const displayColor = toInputHex(caretColor) ?? toInputHex(lastColor) ?? DEFAULT_COLOR; + const active = caretColor !== null; + const inputRef = useRef(null); + // The native picker takes focus from the editor, which drops the selection. Save it on mousedown + // and restore it when a color is picked. + const savedRange = useRef(null); + + const saveSelection = () => { + const sel = window.getSelection(); + savedRange.current = + sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed + ? sel.getRangeAt(0).cloneRange() + : null; + }; + + const applyPicked = (color: string) => { + const range = savedRange.current; + if (range) { + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + formatter.applyColorToSelection(color); + // The saved range is stale after re-wrapping. Save the new selection so another pick in the + // same picker session recolors the same text. + saveSelection(); + } + formatter.setActiveColor(color); + setLastColor(color); + }; + + const removeColor = () => { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed) { + formatter.clearColorInSelection(); + } else { + formatter.setActiveColor(null); + } + }; + + // … renders the "A" button (with the same saveSelection-on-mousedown), the ⌫ button, + // and a hidden whose onChange calls applyPicked. } ``` -`onMouseDown={(e) => e.preventDefault()}` is the key detail — without it, clicking the button moves focus out of the editor and clears the selection before your handler runs. +`onMouseDown={(e) => e.preventDefault()}` keeps focus in the editor. The native color picker still takes focus when it opens, which is why the selection is saved on mousedown and restored when a color is picked. -## Step 3: Wire It Into the Composer +## Step 6: Wire It Into the Composer -Register the formatter with `textFormatters` and mount the button with `toolbarTrailingView`. The toolbar (and therefore the trailing view) only renders when the rich-text editor is enabled, so pass `enableRichTextEditor`. +Create **one** `ColorFormatter` instance and pass it to both the composer's `textFormatters` and the button. The button drives the same instance the composer bound to its editor. Live input requires `enableRichTextEditor`. _File: ChatScreen.tsx_ ```tsx -import { CometChatMessageComposer } from "@cometchat/chat-uikit-react"; +import { useMemo } from "react"; +import type { CometChat } from "@cometchat/chat-sdk-javascript"; +import { CometChatMessageComposer, CometChatMessageList } from "@cometchat/chat-uikit-react"; import { ColorFormatter } from "./formatters/ColorFormatter"; -import { ColorButton } from "./components/ColorButton"; +import { ColorPickerButton } from "./formatters/ColorPickerButton"; -} -/> +function ChatScreen({ group }: { group: CometChat.Group }) { + // One instance, shared by the list, the composer, and the button. + const colorFormatter = useMemo(() => new ColorFormatter(), []); + const formatters = useMemo(() => [colorFormatter], [colorFormatter]); + + return ( + <> + + } + /> + + ); +} ``` -Now: the user selects text, clicks 🎨, and the input becomes `Hello {color=#e5484d}world{/color}`. On send, that raw text is stored on the message. +Registering the formatter on the composer also colors its reply and edit previews. -## Step 4: Render It Everywhere the Message Appears +## Step 7: Register It on Every Surface -The marker only becomes color when a surface runs the formatter. Read-only surfaces — like the message list, conversations, pinned/saved panels — call `format()` to produce the bubble HTML. Register the same formatter on each surface where the message can show up. +A formatter only applies where it is registered. If a surface doesn't have it, readers see the raw `{color:…}` token there. Pass it to every component that shows message text: -_File: ChatScreen.tsx_ +| Component | What it colors | +|:----------|:---------------| +| `CometChatMessageList` | Text bubbles, media captions, quoted replies, and the text the **Copy** option puts on the clipboard | +| `CometChatMessageComposer` | Live input, reply preview, edit preview | +| `CometChatConversations` | Last-message subtitle | +| `CometChatThreadHeader` | The parent message at the top of a thread | +| `CometChatPinnedMessages` | Pinned message rows | +| `CometChatSavedMessages` | Saved message rows | +| `CometChatSearch` | Message search results | +| `CometChatMessageInformation` | The message preview | + +A thread panel has its own message list and composer, so register the formatter there too. ```tsx -import { - CometChatMessageList, - CometChatPinnedMessages, -} from "@cometchat/chat-uikit-react"; -import { ColorFormatter } from "./formatters/ColorFormatter"; +// Display-only surfaces: create the list once, outside the component. +const displayFormatters = [new ColorFormatter()]; + + + + + - +// Thread panel: its own shared instance for the header, list, composer, and button. +const threadColor = useMemo(() => new ColorFormatter(), []); +const threadFormatters = useMemo(() => [threadColor], [threadColor]); -{/* The same message can appear pinned — format it there too. */} - + + +} +/> ``` + +Display-only surfaces can use their own instance. The shared instance matters only for a composer and its button: each composer (main chat, thread) needs its own formatter and its own button. + + -A formatter is only applied where you register it. If you add `textFormatters` to the composer but not the message list, the author sees the marker but readers see raw `{color=...}` text. Register it on every surface that displays the message. +The token is plain text, so every client that shows these messages has to render it. If your users also chat from other platforms, use the same `{color:#hex}…{/color}` token there, or they will see it as raw text. -## How It Round-Trips +## Complete Code + +Drop these two files into your app, then wire them up as in [Step 6](#step-6-wire-it-into-the-composer) and [Step 7](#step-7-register-it-on-every-surface). + + + +```typescript +import { CometChatTextFormatter } from '@cometchat/chat-uikit-react'; + +/** Class on every colored span, in the composer and in rendered messages. */ +const COLOR_CLASS = 'cc-color'; +/** Accepted color values: 3- to 6-digit hex. */ +const HEX = '#[0-9a-fA-F]{3,6}'; +/** Stored token: `{color:#e5484d}text{/color}`. */ +const TOKEN_REGEX = new RegExp(`\\{color:(${HEX})\\}([\\s\\S]*?)\\{/color\\}`, 'g'); +/** + * Upper bound on how many characters one keystroke may color. A fast typist can insert a few + * characters before a `keyup` arrives; a larger jump means the caret moved, not that text was typed. + */ +const MAX_TYPED_RUN = 16; + +/** Content the formatter never colors: mentions and other atomic nodes, code, and links. */ +const DEFAULT_PROTECTED_SELECTORS = ['[contenteditable="false"]', 'code', 'pre', 'a']; + +export interface ColorFormatterOptions { + /** Extra CSS selectors whose content must not be colored (e.g. another formatter's spans). */ + protectedSelectors?: string[]; +} + +/** + * Text color formatter for the CometChat React UI Kit. + * + * Works like a highlighter pen: pick a color and whatever you type next is colored, or select text + * and apply a color to it. In the composer, colored text is a ``. On send it + * is stored as `{color:#hex}text{/color}`, and every surface this formatter is registered on renders + * that token back to colored text. + * + * Requires `enableRichTextEditor` on the composer. Share ONE instance between the composer and its + * toolbar button, and register the formatter on every surface that displays messages. + */ +export class ColorFormatter extends CometChatTextFormatter { + readonly id = 'color'; + override priority = 60; + + private readonly protectedSelectors: string[]; + + /** Pen color applied to newly typed text; `null` when the pen is off. */ + private activeColor: string | null = null; + /** Set when the pen is turned off inside a colored run: the next typed char is moved out of it. */ + private breakoutPending = false; + + private readonly listeners = new Set<() => void>(); + private selectionHandler: (() => void) | null = null; + private domObserver: MutationObserver | null = null; + private wasNonEmpty = false; + + /** + * Caret offset after the last keystroke or click. `formatText` colors only the text typed since, + * so text that was already there is never recolored. + */ + private lastCaret = 0; + private trackedRoot: HTMLElement | null = null; + private readonly caretSync = (): void => { + this.lastCaret = this.getCaretPosition(); + }; + + constructor(options: ColorFormatterOptions = {}) { + super(); + this.protectedSelectors = [...DEFAULT_PROTECTED_SELECTORS, ...(options.protectedSelectors ?? [])]; + } + + // ── Composer lifecycle ───────────────────────────────────────────────────── + + /** Called by the composer once `inputElementReference` is assigned. */ + override initializeComposerTracking(): void { + const root = this.inputElementReference; + if (!root) return; + this.domObserver?.disconnect(); + + // Clicks move the caret without a keyup, so sync `lastCaret` on mouseup too. + if (this.trackedRoot) this.trackedRoot.removeEventListener('mouseup', this.caretSync); + this.trackedRoot = root; + root.addEventListener('mouseup', this.caretSync); + + // Turn the pen off when the input goes from non-empty to empty (message sent or cleared). + this.domObserver = new MutationObserver(() => { + const empty = (root.textContent ?? '').trim() === ''; + if (!empty) { + this.wasNonEmpty = true; + return; + } + if (this.wasNonEmpty) { + this.wasNonEmpty = false; + this.lastCaret = 0; + if (this.activeColor !== null || this.breakoutPending) { + this.activeColor = null; + this.breakoutPending = false; + this.notify(); + } + } + }); + this.domObserver.observe(root, { childList: true, subtree: true, characterData: true }); + } + + // ── Pen state ────────────────────────────────────────────────────────────── + + /** Set the pen color for newly typed text, or `null` to turn the pen off. */ + setActiveColor(color: string | null): void { + this.activeColor = color; + if (color === null && this.caretColorSpan()) this.breakoutPending = true; + this.notify(); + } + + getActiveColor(): string | null { + return this.activeColor; + } + + /** + * Subscribe to changes of the pen or of the caret position. Shaped for React's + * `useSyncExternalStore(formatter.onColorChange, formatter.getCaretColor)`. + */ + onColorChange = (listener: () => void): (() => void) => { + if (this.listeners.size === 0) { + this.selectionHandler = () => this.notify(); + document.addEventListener('selectionchange', this.selectionHandler); + } + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + if (this.listeners.size === 0 && this.selectionHandler) { + document.removeEventListener('selectionchange', this.selectionHandler); + this.selectionHandler = null; + } + }; + }; + + /** Color of the text at the caret, or `null` when the caret is not in colored text. */ + getCaretColor = (): string | null => { + const span = this.caretColorSpan(); + return span ? this.colorOf(span) : null; + }; + + private notify(): void { + for (const listener of this.listeners) listener(); + } + + // ── Live typing ──────────────────────────────────────────────────────────── + + override onKeyUp(event: KeyboardEvent): void { + // Skip IME composition; the committed text arrives with a later keyup. + if (event.isComposing || event.keyCode === 229) return; + this.formatText(); + this.lastCaret = this.getCaretPosition(); + } + + /** Color the text typed since the last keystroke according to the pen. */ + override formatText(): void { + const root = this.inputElementReference; + if (!root) return; + const sel = this.selection(); + if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return; + const range = sel.getRangeAt(0); + const node = range.startContainer; + const offset = range.startOffset; + if (!root.contains(node) || node.nodeType !== Node.TEXT_NODE || offset === 0) { + if (this.activeColor === null) this.breakoutPending = false; + return; + } + if (this.isProtected(node, root)) return; + + const text = node as Text; + const span = this.closestColorSpan(text); + const caret = this.getCaretPosition(); + + // Pen was turned off inside a colored run: move the typed char out, uncolored. + if (this.breakoutPending) { + this.breakoutPending = false; + if (span) { + this.breakOutCharBefore(text, offset, span, null); + this.setCaretPosition(caret); + this.reRender(); + } + return; + } + + if (this.activeColor === null) return; + // The browser already typed into a run of the pen's color. + if (span && this.colorOf(span) === this.activeColor) return; + + if (span) { + // Typed inside a run of a different color: move the char out and wrap it in the pen color. + this.breakOutCharBefore(text, offset, span, this.activeColor); + } else { + // Color everything typed since the last keystroke. A delta outside [1, min(offset, cap)] + // means the caret jumped, so color just the last char. + const delta = caret - this.lastCaret; + const runLen = delta >= 1 && delta <= MAX_TYPED_RUN && delta <= offset ? delta : 1; + this.wrapRunBefore(text, offset, runLen, this.activeColor); + } + this.setCaretPosition(caret); + this.reRender(); + } + + // ── Selection actions ────────────────────────────────────────────────────── + + /** Apply `color` to the current selection, replacing any color already there. */ + applyColorToSelection(color: string): void { + const root = this.inputElementReference; + if (!root) return; + const sel = this.selection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; + const spans: HTMLElement[] = []; + for (const { node, start, end } of this.collectSlices(sel.getRangeAt(0), root)) { + const existing = this.closestColorSpan(node); + spans.push( + existing + ? this.recolorSlice(existing, node, start, end, color) + : this.wrapSlice(node, start, end, color) + ); + } + if (spans.length === 0) return; + this.mergeRun(spans); + this.reselect(spans); + this.reRender(); + } + + /** Remove color from the current selection. Colored text outside the selection keeps its color. */ + clearColorInSelection(): void { + const root = this.inputElementReference; + if (!root) return; + const sel = this.selection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; + const bare: Text[] = []; + for (const { node, start, end } of this.collectSlices(sel.getRangeAt(0), root)) { + const existing = this.closestColorSpan(node); + bare.push(existing ? this.unwrapSlice(existing, node, start, end) : node); + } + const first = bare[0]; + const last = bare[bare.length - 1]; + if (first && last) { + const range = this.doc().createRange(); + range.setStart(first, 0); + range.setEnd(last, last.length); + sel.removeAllRanges(); + sel.addRange(range); + } + this.reRender(); + } + + // ── Token round-trip ─────────────────────────────────────────────────────── + + /** + * Token → HTML. Used by every display surface (the base `format()` delegates here) and to restore + * colors after a paste. + */ + override customLogicToFormatText(text: string): string { + return text.replace(TOKEN_REGEX, `$2`); + } + + /** Composer HTML → token. Used on send and before a paste is sanitized. */ + override getOriginalText(inputText?: string): string { + if (inputText === undefined) return super.getOriginalText(); + if (!inputText.includes(COLOR_CLASS)) return inputText; + // Walk the DOM rather than using a regex: a colored run can contain another element (e.g. a + // mention), and a non-greedy `…` regex would stop at that element's closing tag. + const doc = new DOMParser().parseFromString(inputText, 'text/html'); + doc.querySelectorAll(`span.${COLOR_CLASS}`).forEach(el => { + const parent = el.parentNode; + if (!parent) return; + const color = this.colorOf(el as HTMLElement); + // Replace the span with `{color:#hex}` + its children + `{/color}`. + if (color) parent.insertBefore(doc.createTextNode(`{color:${color}}`), el); + while (el.firstChild) parent.insertBefore(el.firstChild, el); + if (color) parent.insertBefore(doc.createTextNode('{/color}'), el); + parent.removeChild(el); + }); + return doc.body.innerHTML; + } + + // ── DOM helpers ──────────────────────────────────────────────────────────── + + private doc(): Document { + return this.inputElementReference?.ownerDocument ?? document; + } + + private selection(): Selection | null { + return this.doc().defaultView?.getSelection() ?? null; + } + + private colorOf(el: HTMLElement): string { + return new RegExp(`color:\\s*(${HEX})`).exec(el.getAttribute('style') ?? '')?.[1] ?? ''; + } + + private closestColorSpan(node: Node): HTMLElement | null { + let el: Node | null = node.nodeType === Node.TEXT_NODE ? node.parentNode : node; + const root = this.inputElementReference; + while (el && el !== root) { + if (el.nodeType === Node.ELEMENT_NODE && (el as HTMLElement).classList.contains(COLOR_CLASS)) { + return el as HTMLElement; + } + el = el.parentNode; + } + return null; + } + + private caretColorSpan(): HTMLElement | null { + const sel = this.selection(); + if (!sel || sel.rangeCount === 0) return null; + const node = sel.getRangeAt(0).startContainer; + return this.inputElementReference?.contains(node) ? this.closestColorSpan(node) : null; + } + + private isProtected(node: Node, root: HTMLElement): boolean { + let el = node.parentElement; + while (el && el !== root) { + const current = el; + if (this.protectedSelectors.some(selector => current.matches(selector))) return true; + el = el.parentElement; + } + return false; + } + + private makeSpan(color: string): HTMLElement { + const span = this.doc().createElement('span'); + span.className = COLOR_CLASS; + span.setAttribute('style', `color:${color}`); + return span; + } + + /** Wrap `[start, end)` of a text node in a new colored span. */ + private wrapSlice(node: Text, start: number, end: number, color: string): HTMLElement { + const mid = start > 0 ? node.splitText(start) : node; + if (end - start < mid.length) mid.splitText(end - start); + const span = this.makeSpan(color); + mid.parentNode?.insertBefore(span, mid); + span.appendChild(mid); + return span; + } + + /** Recolor `[start, end)` of a run; the text before and after keeps the old color. */ + private recolorSlice( + mark: HTMLElement, + node: Text, + start: number, + end: number, + color: string + ): HTMLElement { + const parent = mark.parentNode; + if (!parent) return this.makeSpan(color); + const mid = start > 0 ? node.splitText(start) : node; + if (end - start < mid.length) mid.splitText(end - start); + const trailing = this.detachTrailing(mark, mid); + const span = this.makeSpan(color); + span.appendChild(mid); + const anchor = mark.nextSibling; + parent.insertBefore(span, anchor); + if (trailing) parent.insertBefore(trailing, anchor); + if (!mark.firstChild) parent.removeChild(mark); + return span; + } + + /** Uncolor `[start, end)` of a run; the text before and after keeps its color. */ + private unwrapSlice(mark: HTMLElement, node: Text, start: number, end: number): Text { + const parent = mark.parentNode; + if (!parent) return node; + const mid = start > 0 ? node.splitText(start) : node; + if (end - start < mid.length) mid.splitText(end - start); + const trailing = this.detachTrailing(mark, mid); + const anchor = mark.nextSibling; + parent.insertBefore(mid, anchor); + if (trailing) parent.insertBefore(trailing, anchor); + if (!mark.firstChild) parent.removeChild(mark); + return mid; + } + + /** Move everything after `from` inside `mark` into a clone of `mark`, which keeps the color. */ + private detachTrailing(mark: HTMLElement, from: Node): HTMLElement | null { + let sib: ChildNode | null = from.nextSibling; + if (!sib) return null; + const trailing = mark.cloneNode(false) as HTMLElement; + while (sib) { + const next: ChildNode | null = sib.nextSibling; + trailing.appendChild(sib); + sib = next; + } + return trailing; + } + + /** Color the `count` characters before `offset` and merge them into an adjacent same-color run. */ + private wrapRunBefore(text: Text, offset: number, count: number, color: string): void { + const span = this.wrapSlice(text, Math.max(0, offset - count), offset, color); + this.mergeRun([span]); + } + + /** + * Move the char before `offset` out of `span`, re-wrapped in `color` (or left uncolored when + * `color` is null). Text after the char stays in the original color. + */ + private breakOutCharBefore( + text: Text, + offset: number, + span: HTMLElement, + color: string | null + ): void { + const parent = span.parentNode; + if (!parent) return; + const charNode = offset - 1 > 0 ? text.splitText(offset - 1) : text; + if (charNode.length > 1) charNode.splitText(1); + const trailing = this.detachTrailing(span, charNode); + const anchor = span.nextSibling; + if (color) { + const newSpan = this.makeSpan(color); + newSpan.appendChild(charNode); + parent.insertBefore(newSpan, anchor); + this.mergeRun([newSpan]); + } else { + parent.insertBefore(charNode, anchor); + } + if (trailing) parent.insertBefore(trailing, anchor); + if (!span.firstChild) parent.removeChild(span); + } + + /** Merge each span with an adjacent sibling of the same color. */ + private mergeRun(spans: HTMLElement[]): void { + const isSameColor = (a: HTMLElement, b: Node | null): b is HTMLElement => + !!b && + b.nodeType === Node.ELEMENT_NODE && + (b as HTMLElement).classList.contains(COLOR_CLASS) && + this.colorOf(b as HTMLElement) === this.colorOf(a); + for (const span of spans) { + if (!span.isConnected) continue; + const prev = span.previousSibling; + if (isSameColor(span, prev)) { + while (span.firstChild) prev.appendChild(span.firstChild); + span.remove(); + continue; + } + const next = span.nextSibling; + if (isSameColor(span, next)) { + while (next.firstChild) span.appendChild(next.firstChild); + next.remove(); + } + } + } + /** Select from the first to the last of `spans`. */ + private reselect(spans: HTMLElement[]): void { + const alive = spans.filter(s => s.isConnected); + const first = alive[0]; + const last = alive[alive.length - 1]; + if (!first || !last) return; + const range = this.doc().createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const sel = this.selection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + + /** The selected part of each unprotected text node in `range`. */ + private collectSlices( + range: Range, + root: HTMLElement + ): { node: Text; start: number; end: number }[] { + const startNode = range.startContainer; + const endNode = range.endContainer; + const walker = this.doc().createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, { + acceptNode: n => + range.intersectsNode(n) && (n.textContent ?? '') !== '' + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }); + const nodes: Text[] = []; + let n = walker.nextNode(); + while (n) { + nodes.push(n as Text); + n = walker.nextNode(); + } + if (nodes.length === 0 && startNode === endNode && startNode.nodeType === Node.TEXT_NODE) { + nodes.push(startNode as Text); + } + return nodes + .map(node => ({ + node, + start: node === startNode ? range.startOffset : 0, + end: node === endNode ? range.endOffset : node.length, + })) + .filter(slice => slice.end > slice.start && !this.isProtected(slice.node, root)); + } +} ``` -Composer (author) Wire format Bubble (reader) -───────────────── ─────────── ─────────────── -select "world" Hello {color=#e5484d} Hello world -click 🎨 → world{/color} → (in red, via format()) + + + + + +```tsx +import { useRef, useState, useSyncExternalStore } from 'react'; +import type { ColorFormatter } from './ColorFormatter'; + +const DEFAULT_COLOR = '#E7413F'; + +/** Normalize `#rgb` / `#rrggbb` to the lowercase `#rrggbb` that `` requires. */ +function toInputHex(hex: string | null): string | null { + if (!hex) return null; + if (/^#[0-9a-fA-F]{6}$/.test(hex)) return hex.toLowerCase(); + if (/^#[0-9a-fA-F]{3}$/.test(hex)) { + const [, r, g, b] = hex; + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); + } + return null; +} + +export interface ColorPickerButtonProps { + /** The same `ColorFormatter` instance passed to the composer's `textFormatters`. */ + formatter: ColorFormatter; +} + +/** + * Toolbar controls for `ColorFormatter`, meant for the composer's `toolbarTrailingView`. + * + * - **A** opens a color picker. Picking a color colors the current selection (if any) and turns the + * pen on, so the text typed next is colored too. + * - **⌫** removes color from the selection, or turns the pen off when nothing is selected. + * + * The "A" and its underline show the color at the caret, falling back to the last color picked. + */ +export function ColorPickerButton({ formatter }: ColorPickerButtonProps) { + const [lastColor, setLastColor] = useState(DEFAULT_COLOR); + const caretColor = useSyncExternalStore(formatter.onColorChange, formatter.getCaretColor); + const displayColor = toInputHex(caretColor) ?? toInputHex(lastColor) ?? DEFAULT_COLOR; + const active = caretColor !== null; + const inputRef = useRef(null); + // The native picker takes focus from the editor, which drops the selection. Save it on mousedown + // and restore it when a color is picked. + const savedRange = useRef(null); + + const saveSelection = () => { + const sel = window.getSelection(); + savedRange.current = + sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed + ? sel.getRangeAt(0).cloneRange() + : null; + }; + + const applyPicked = (color: string) => { + const range = savedRange.current; + if (range) { + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + formatter.applyColorToSelection(color); + // The saved range is stale after re-wrapping. Save the new selection so another pick in the + // same picker session recolors the same text. + saveSelection(); + } + formatter.setActiveColor(color); + setLastColor(color); + }; + + const removeColor = () => { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed) { + formatter.clearColorInSelection(); + } else { + formatter.setActiveColor(null); + } + }; + + return ( + + + + {/* Hidden native color input, opened by the "A" button. */} + applyPicked(e.target.value)} + style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }} + /> + + ); +} ``` -The marker is plain text on the message, so it survives storage and delivery untouched; each display surface turns it into color independently through the formatter you registered. + ## Next Steps -- [Text Formatters](/ui-kit/react/plugins/text-formatters) — the built-in formatters and the full `CometChatTextFormatter` API -- [Message Composer → toolbarTrailingView](/ui-kit/react/components/message-composer#toolbartrailingview) — the toolbar slot in detail -- [Message Bubble](/ui-kit/react/components/message-bubble) — how bubbles render message content +- [Text Formatters](/ui-kit/react/plugins/text-formatters): the built-in formatters and the full `CometChatTextFormatter` API +- [Message Composer → toolbarTrailingView](/ui-kit/react/components/message-composer#toolbartrailingview): the toolbar slot in detail +- [Threaded Messages](/ui-kit/react/guide-threaded-messages): building the thread panel From 79dfe4210570322c9b29b170276fa54814a5de65 Mon Sep 17 00:00:00 2001 From: PrajwalDhuleCC Date: Fri, 25 Sep 2026 17:28:55 +0530 Subject: [PATCH 4/4] docs(sdk/javascript): name the real error for unpinning a system-pinned conversation --- sdk/javascript/pin-conversation.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/javascript/pin-conversation.mdx b/sdk/javascript/pin-conversation.mdx index 78c280666..19a54bc13 100644 --- a/sdk/javascript/pin-conversation.mdx +++ b/sdk/javascript/pin-conversation.mdx @@ -84,7 +84,7 @@ It resolves with the full updated `Conversation`, with `pinnedAt` and `pinnedBy` -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. +A user cannot unpin an admin-global pin — that call is rejected with `ERR_SYSTEM_PINNED_CONVERSATION`. Hide or disable the unpin control for conversations you know are system-pinned. ## Fetch Pinned Conversations @@ -241,7 +241,7 @@ Both resolve to `null` when the app settings carry no value. `getSystemPinnedCon | --- | --- | | `ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED` | The user's conversation pin cap was reached (HTTP 400). | | `ERR_UID_NOT_FOUND` / `ERR_GUID_NOT_FOUND` | The peer named by `conversationWith` does not exist (HTTP 404). | -| `ERR_ACTION_NOT_ALLOWED` | The action was refused — including an attempt to unpin a **system-pinned** conversation, which a user may never unpin. | +| `ERR_SYSTEM_PINNED_CONVERSATION` | The conversation is **system-pinned** (admin-global), which a user may never unpin. | Pins are per-user, so pinning a conversation never affects anyone else's list. Check `conversation.isSystemPinned()` and hide the unpin control rather than letting the call fail.