diff --git a/sdk/javascript/pin-conversation.mdx b/sdk/javascript/pin-conversation.mdx
index 803dec74d..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
@@ -233,6 +233,53 @@ 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 (HTTP 400). |
+| `ERR_UID_NOT_FOUND` / `ERR_GUID_NOT_FOUND` | The peer named by `conversationWith` does not exist (HTTP 404). |
+| `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.
+
+Both calls are idempotent: unpinning a conversation that was never pinned succeeds rather than erroring.
+
+
+
+ ```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()`, 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 a22837e7e..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
@@ -239,6 +239,50 @@ 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 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). |
+
+
+
+ ```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()`, 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
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..18045552b 100644
--- a/sdk/javascript/save-message.mdx
+++ b/sdk/javascript/save-message.mdx
@@ -246,6 +246,51 @@ 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 (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: any participant can save a message they have access to, up to the cap.
+
+
+
+ ```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()`, 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 08b4d7726..33226476f 100644
--- a/sdk/javascript/thread-subscription.mdx
+++ b/sdk/javascript/thread-subscription.mdx
@@ -307,6 +307,74 @@ 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. |
+
+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.
+
+
+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..98f123873 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. |
+
+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
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 its own cap:
-| 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-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
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
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.