From bd60ff063830ad0ae7bd7ff2abdfda186b778cb4 Mon Sep 17 00:00:00 2001 From: aanshisingh-cometchat Date: Fri, 7 Aug 2026 08:18:21 +0530 Subject: [PATCH 1/6] docs(ui-kit/angular): thread subscription, pin & save messages and conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the three features shipped in Angular UI Kit v5.2.0 (cometchat-team/uikit-angular#559). New pages: - components/cometchat-pinned-messages — the conversation-wide pinned panel - components/cometchat-saved-messages — the private, cross-conversation panel - guides/thread-subscription — follow/unfollow end to end: the feature gate, both surfaces, ThreadSubscriptionService, events, and the optimistic/ debounce/revert behaviour - guides/pin-and-save-messages — pin and save messages, pin conversations, PinSaveService, events, limits and localization Updated where the feature landed: thread-header (follow control, hideThreadSubscriptionToggle, trailingView, threadSubscriptionChange), message-list (five hide-inputs, threadSubscriptionChange, the Organise flyout), message-header (showPinnedMessagesOption, pinnedMessagesClick), conversations (hidePinConversation, pinned-first ordering), conversation-item and message-bubble (the markers), events (CometChatThreadEvents, CometChatPinSaveEvents), global-config (the three feature gates), core-features, both overviews, and docs.json navigation. Written against the implementation rather than the PR description, which corrects three points the source's own docs and JSDoc get wrong: - the pinned panel handles Message Information internally; only Translate and Report are forwarded via messageOptionClick - thread-header's trailingView replaces only the follow control, not the close button - CometChatPinSaveEvents emits source "optimistic"/"revert"/"sdk", not "local" No Storybook iframes on the two new panel pages: neither component has a published story yet. Co-Authored-By: Claude Opus 5 --- docs.json | 9 + .../cometchat-conversation-item.mdx | 19 + .../components/cometchat-conversations.mdx | 38 ++ .../components/cometchat-message-bubble.mdx | 46 +++ .../components/cometchat-message-header.mdx | 45 +++ .../components/cometchat-message-list.mdx | 52 +++ .../components/cometchat-pinned-messages.mdx | 259 +++++++++++++ .../components/cometchat-saved-messages.mdx | 186 ++++++++++ .../components/cometchat-thread-header.mdx | 89 ++++- .../components/components-overview.mdx | 4 + ui-kit/angular/core-features.mdx | 26 ++ .../angular/customization/global-config.mdx | 45 ++- ui-kit/angular/events.mdx | 48 +++ ui-kit/angular/guides/guides-overview.mdx | 2 + .../angular/guides/pin-and-save-messages.mdx | 350 ++++++++++++++++++ ui-kit/angular/guides/thread-subscription.mdx | 287 ++++++++++++++ 16 files changed, 1501 insertions(+), 4 deletions(-) create mode 100644 ui-kit/angular/components/cometchat-pinned-messages.mdx create mode 100644 ui-kit/angular/components/cometchat-saved-messages.mdx create mode 100644 ui-kit/angular/guides/pin-and-save-messages.mdx create mode 100644 ui-kit/angular/guides/thread-subscription.mdx diff --git a/docs.json b/docs.json index cb43da08b..66f508875 100644 --- a/docs.json +++ b/docs.json @@ -2723,6 +2723,13 @@ "ui-kit/angular/components/cometchat-thread-header" ] }, + { + "group": "Pinned & Saved", + "pages": [ + "ui-kit/angular/components/cometchat-pinned-messages", + "ui-kit/angular/components/cometchat-saved-messages" + ] + }, { "group": "Message Bubbles", "pages": [ @@ -2787,6 +2794,8 @@ "ui-kit/angular/guides/guides-overview", "ui-kit/angular/guides/state-management", "ui-kit/angular/guides/threaded-messages", + "ui-kit/angular/guides/thread-subscription", + "ui-kit/angular/guides/pin-and-save-messages", "ui-kit/angular/guides/group-chat", "ui-kit/angular/guides/new-chat", "ui-kit/angular/guides/block-unblock-user", diff --git a/ui-kit/angular/components/cometchat-conversation-item.mdx b/ui-kit/angular/components/cometchat-conversation-item.mdx index 2cc5f2946..4085d6a26 100644 --- a/ui-kit/angular/components/cometchat-conversation-item.mdx +++ b/ui-kit/angular/components/cometchat-conversation-item.mdx @@ -21,8 +21,21 @@ This component provides: - **8 Granular Events**: Respond to precise user interactions - **Display Configuration**: Hide receipts, user status, or group type icons - **Context Menu Support**: Customizable actions for each conversation +- **Pin Marker**: Marks a pinned conversation in the trailing area, alongside the unread badge - **OnPush Change Detection**: Optimized for performance +### Pinned Conversations + +A conversation the user has pinned is marked in the row's trailing area. The state is derived from the conversation itself — there is no input for it: + +```typescript +const isPinned = conversation.isPinned?.(); +``` + +The presence of `pinnedAt` *is* the boolean; an unpinned conversation carries no key at all, so this is never "pinned at 0". `isPinned()` covers both a personal pin and an app-wide one, and the accessor is optional-called because older Chat SDK builds do not expose it. + +The marker itself is `aria-hidden`, so the state reaches screen readers through the row's accessible label instead — otherwise pinned and unpinned rows would be indistinguishable without sight. Pinning is performed from [CometChatConversations](/ui-kit/angular/components/cometchat-conversations#pinned-conversations), which also owns the ordering. + ## Basic Usage ### Simple Implementation @@ -645,6 +658,12 @@ cometchat-conversation-item { --cometchat-conversations-status-size: 14px; --cometchat-conversations-status-online-color: var(--cometchat-success-color); --cometchat-conversations-status-offline-color: var(--cometchat-neutral-color-400); + + /* Pin marker (trailing area, beside the unread badge) */ + --cometchat-conversations-pin-width: 16px; + --cometchat-conversations-pin-height: 16px; + --cometchat-conversations-pin-color: var(--cometchat-icon-color-secondary); + --cometchat-conversations-tail-markers-gap: var(--cometchat-spacing-1); /* Title */ --cometchat-conversations-title-text-font: var(--cometchat-font-body-medium); diff --git a/ui-kit/angular/components/cometchat-conversations.mdx b/ui-kit/angular/components/cometchat-conversations.mdx index 695589a0f..3964fdb9c 100644 --- a/ui-kit/angular/components/cometchat-conversations.mdx +++ b/ui-kit/angular/components/cometchat-conversations.mdx @@ -156,6 +156,7 @@ export class ChatComponent { |----------|------|---------|-------------| | `hideReceipts` | `boolean` | `false` | Hide message read receipts in conversation items | | `hideError` | `boolean` | `false` | Hide error views when errors occur | +| `hidePinConversation` | `boolean` | `false` | Hide the pin/unpin option in the row context menu. See [Pinned Conversations](#pinned-conversations) | | `hideDeleteConversation` | `boolean` | `false` | Hide delete option in context menu | | `hideUserStatus` | `boolean` | `false` | Hide online/offline status indicators | | `hideGroupType` | `boolean` | `false` | Hide group type icons for group conversations | @@ -343,6 +344,43 @@ export class ChatComponent { +## Pinned Conversations + +Each row's context menu carries a **Pin conversation** / **Unpin conversation** entry, placed above Delete — the safe, reversible action comes first. Pinning is per-user: it rearranges this user's list and changes nothing anyone else sees. + +```html expandable + + +``` + +The option renders whenever the installed Chat SDK exposes `pinConversation` / `unpinConversation`. There is no app-level feature flag for it, unlike [pinning a message](/ui-kit/angular/guides/pin-and-save-messages). + +### Ordering + +Pinned conversations are lifted above unpinned ones. The partition is **stable**: recency still decides the order among pinned chats and among unpinned ones, so a pinned chat with a new message still rises to the top of its block, and a message in an unpinned chat can never push the pinned block down. + +An unpinned list is returned unchanged, so the common case pays nothing for this. + +### Reading the State + +```typescript +const isPinned = conversation.isPinned?.(); +``` + +The presence of `pinnedAt` *is* the boolean — an unpinned conversation carries no key at all, so this is never "pinned at 0". `isPinned()` covers both a personal pin and an app-wide one (`pinnedBy === "app_system"`). The accessor is optional-called because older Chat SDK builds do not expose it. + +### Behavior + +| Behavior | Detail | +|:---|:---| +| Confirmation | Pinning runs straight away; **unpinning asks first**. A pin is a deliberate arrangement of the list, and a misplaced click should not undo it | +| Marker | The row shows a pin marker, styled with `--cometchat-conversations-pin-color`, `--cometchat-conversations-pin-width`, and `--cometchat-conversations-pin-height` | +| Accessibility | The marker is `aria-hidden`; the state reaches screen readers through the row's own accessible label | +| Limits | The pin cap is server-owned. Exceeding it shows "You can pin up to N conversations." with the real number read from the rejection | +| New-message sound | The list scans every row rather than index 0 when deciding whether to play a sound, so a pinned chat sitting first does not silence it | + ## Advanced Usage ### Filtering Conversations diff --git a/ui-kit/angular/components/cometchat-message-bubble.mdx b/ui-kit/angular/components/cometchat-message-bubble.mdx index b4d7c8c12..8812de4c4 100644 --- a/ui-kit/angular/components/cometchat-message-bubble.mdx +++ b/ui-kit/angular/components/cometchat-message-bubble.mdx @@ -289,6 +289,52 @@ Most of these delegate the media rendering to their single-attachment counterpar [`CometChatMessageComposer`](/ui-kit/angular/components/cometchat-message-composer#multiple-attachments) has an `enableMultipleAttachments` input, but it governs only the **send** side — whether picking files stages them in a tray for one batched send. It has no effect on rendering. +### Pinned and Saved Indicators + +A pinned or saved message is marked in the bubble's status-info footer, beside the timestamp. Both are derived from the message itself — there is no input for either: + +| Indicator | Derived from | Who sees it | +| --- | --- | --- | +| Pin marker | `message.getPinnedAt()` | Everyone in the conversation — a pin is conversation-wide | +| Bookmark marker | `message.getSavedAt()` | Only the user who saved it — `savedAt` is simply not present in anyone else's copy | + +The presence of the timestamp *is* the boolean: the backend omits these fields entirely when unset, so an absent value means "not pinned" / "not saved", never zero. Neither indicator shows on a deleted message. + +A batched media message normally shows its footer on the last bubble only. A pinned or saved bubble keeps its footer regardless — hiding it would hide the very badge that explains the mark. + +```css expandable +/* Both markers */ +.cometchat-message-bubble__pinned-indicator, +.cometchat-message-bubble__saved-indicator { + background-color: var(--cometchat-message-bubble-meta-indicator-color, var(--cometchat-text-color-secondary)); +} + +/* Outgoing bubbles invert them */ +.cometchat-message-bubble__wrapper--outgoing .cometchat-message-bubble__pinned-indicator, +.cometchat-message-bubble__wrapper--outgoing .cometchat-message-bubble__saved-indicator { + background-color: var(--cometchat-message-bubble-meta-indicator-color-outgoing, var(--cometchat-text-color-white)); +} + +/* Individual sizes */ +.cometchat-message-bubble__pinned-indicator { + width: var(--cometchat-message-bubble-pinned-indicator-width, 7px); + height: var(--cometchat-message-bubble-pinned-indicator-height, 11px); +} + +.cometchat-message-bubble__saved-indicator { + width: var(--cometchat-message-bubble-saved-indicator-width, 9px); + height: var(--cometchat-message-bubble-saved-indicator-height, 11px); +} + +/* Dot between the markers and the timestamp */ +.cometchat-message-bubble__meta-separator { + width: var(--cometchat-message-bubble-meta-separator-size, 3px); + height: var(--cometchat-message-bubble-meta-separator-size, 3px); +} +``` + +Providing a [`statusInfoView`](#statusinfoview) replaces the footer wholesale, indicators included — render them yourself from `getPinnedAt()` / `getSavedAt()` if you need them. See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages) for the full feature. + ## Bubble Parts Customization Each bubble part can be customized independently using Angular `TemplateRef`. The customization follows a priority system: diff --git a/ui-kit/angular/components/cometchat-message-header.mdx b/ui-kit/angular/components/cometchat-message-header.mdx index 609d5bff2..840453bbf 100644 --- a/ui-kit/angular/components/cometchat-message-header.mdx +++ b/ui-kit/angular/components/cometchat-message-header.mdx @@ -138,6 +138,7 @@ export class GroupChatComponent { | `hideVideoCallButton` | `boolean` | `true` | Hide the video call button. Defaults to `true` (hidden). When calling is enabled via `UIKitSettingsBuilder.setCallingEnabled(true)`, the resolved default becomes `false` (visible). Set to `true` explicitly to hide even when calling is enabled. | | `showSearchOption` | `boolean` | `false` | Show the search option in the header | | `showConversationSummaryButton` | `boolean` | `false` | Show the AI conversation summary button | +| `showPinnedMessagesOption` | `boolean` | `false` | Add a "Pinned messages" entry to the overflow menu. The header only asks for the panel — the host opens it. See [Pinned Messages](#pinned-messages) | | `callSettingsBuilder` | `CallSettingsBuilder` | `undefined` | Custom `CallSettingsBuilder` forwarded to the call buttons and ongoing call screen. Follows the three-tier priority: @Input > [GlobalConfig](/ui-kit/angular/customization/global-config) > default. | ### AI Configuration Properties @@ -176,8 +177,52 @@ export class GroupChatComponent { | `conversationSummaryClick` | `{ messageCount: number }` | Emitted when the conversation summary button is clicked or auto-generation is triggered | | `voiceCallClick` | `CometChat.User \| CometChat.Group` | Emitted when the voice call button is clicked | | `videoCallClick` | `CometChat.User \| CometChat.Group` | Emitted when the video call button is clicked | +| `pinnedMessagesClick` | `void` | Emitted when "Pinned messages" is selected from the overflow menu | | `error` | `CometChat.CometChatException` | Emitted when an error occurs in the component or service | +## Pinned Messages + +Set `showPinnedMessagesOption` to add a **Pinned messages** entry to the overflow menu. The header emits `pinnedMessagesClick` and does nothing else — where the panel appears is the host's decision. + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { + CometChatMessageHeaderComponent, + CometChatPinnedMessagesComponent, +} from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-messages', + standalone: true, + imports: [CometChatMessageHeaderComponent, CometChatPinnedMessagesComponent], + template: ` + + + + @if (showPinned) { + + + } + `, +}) +export class MessagesComponent { + group!: CometChat.Group; + showPinned = false; +} +``` + + + The overflow menu appears only once **more than one** of `showSearchOption`, `showConversationSummaryButton`, and `showPinnedMessagesOption` is set. A single action stays a plain button rather than hiding behind a ⋮. + + +Saved messages has no header entry point by design: a save is per-user and spans every conversation, so hanging it off one chat would misrepresent what it contains. Put [CometChatSavedMessages](/ui-kit/angular/components/cometchat-saved-messages) in your app chrome instead. + ## Usage Patterns CometChatMessageHeader supports two usage patterns for receiving the active user or group context. diff --git a/ui-kit/angular/components/cometchat-message-list.mdx b/ui-kit/angular/components/cometchat-message-list.mdx index 262b6c0dd..a0d7411a1 100644 --- a/ui-kit/angular/components/cometchat-message-list.mdx +++ b/ui-kit/angular/components/cometchat-message-list.mdx @@ -335,6 +335,11 @@ This section provides a complete reference of all @Input properties, @Output eve | `hideGroupActionMessages` | `boolean` | `false` | Hides system messages for group actions (member joined, left, etc.). | | `hideError` | `boolean` | `false` | Hides error views when errors occur during message loading. | | `hideReplyInThreadOption` | `boolean` | `false` | Hides the "Reply in Thread" option from message context menu. | +| `hideThreadSubscriptionOption` | `boolean` | `false` | Hides the thread follow/unfollow option from the message context menu, without turning the feature off. See [Thread Subscription](#thread-subscription). | +| `hidePinMessageOption` | `boolean` | `false` | Hides "Pin message" from the Organise flyout. See [Pin and Save](#pin-and-save). | +| `hideUnpinMessageOption` | `boolean` | `false` | Hides "Unpin message" from the Organise flyout. | +| `hideSaveMessageOption` | `boolean` | `false` | Hides "Save message" from the Organise flyout. | +| `hideUnsaveMessageOption` | `boolean` | `false` | Hides "Unsave message" from the Organise flyout. | | `hideTranslateMessageOption` | `boolean` | `false` | Hides the "Translate" option from message context menu. | | `hideEditMessageOption` | `boolean` | `false` | Hides the "Edit" option from message context menu (only shown for own text messages). | | `hideDeleteMessageOption` | `boolean` | `false` | Hides the "Delete" option from message context menu (only shown for own messages). | @@ -384,6 +389,53 @@ This section provides a complete reference of all @Input properties, @Output eve | `conversationStarterClick` | `string` | Emitted when a conversation starter is clicked. The payload is the starter text. | | `messagePrivatelyClick` | `{ message: CometChat.BaseMessage, user: CometChat.User }` | Emitted when "Message Privately" option is clicked in a group chat. | | `replyClick` | `CometChat.BaseMessage` | Emitted when the "Reply" option is clicked on a message. Handle this to show reply preview in composer. | +| `threadSubscriptionChange` | `IThreadSubscriptionChanged` | Emitted when a thread's follow state changes — from this list, from the thread header, or from a server-side auto-subscribe. Payload: `{ parentMessageId: number, state: CometChat.ThreadSubscriptionState, source: string }`. | + +### Thread Subscription + +When the feature is enabled, the context menu carries a single follow/unfollow option immediately after **Reply in thread**, so the two thread actions stay together. Its title flips with the state: "Notify me about replies" when the user does not follow the thread, "Stop reply notifications" when they do. + +The option renders only when `enableThreadSubscription: true` is provided through [`COMETCHAT_GLOBAL_CONFIG`](/ui-kit/angular/customization/global-config#enablethreadsubscription) — the feature is **off by default** — and `hideThreadSubscriptionOption` is `false`. + +```html expandable + + +``` + +The option is offered on messages with **zero replies** — following a message before anyone answers is the point of it — and on replies as well. + + + On a reply, the action resolves to the reply's **parent**, never the reply's own ID. CometChat has no nested threads, and a subscription rooted at a reply would write a thread-list row pointing at a thread that cannot be opened. + + +See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) for the full feature. + +### Pin and Save + +**Pin message** and **Save message** are gathered under an **Organise ▸** flyout, placed before Translate. The flyout is omitted entirely when neither action applies, rather than opening onto nothing. + +The menu shows Pin **or** Unpin, and Save **or** Unsave — never a toggling third state — because the presence of `pinnedAt` / `savedAt` on the message *is* the boolean. + +```html expandable + + +``` + +| Behavior | Detail | +|:---|:---| +| Feature gate | The app-level flags reported by `CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()`, overridable via `enablePinMessage` / `enableSaveMessage` in global config | +| Eligibility | Excludes deleted, in-flight, moderation-held, and `action` category messages. Thread replies **are** eligible | +| Pin permission | In a group, a known participant is denied; everyone else is offered the option and the server is the backstop. Saving has no scope gate | +| Confirmation | Pin and Save run immediately; **Unpin and Unsave ask first** | +| Indicators | Pinned and saved bubbles keep their status-info footer and show a marker there, even mid-batch | + +See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages) for the full feature. ### Methods diff --git a/ui-kit/angular/components/cometchat-pinned-messages.mdx b/ui-kit/angular/components/cometchat-pinned-messages.mdx new file mode 100644 index 000000000..78706f8f0 --- /dev/null +++ b/ui-kit/angular/components/cometchat-pinned-messages.mdx @@ -0,0 +1,259 @@ +--- +title: "Pinned Messages" +description: "A panel component listing the pinned messages of a one-on-one or group conversation" +--- + +The `CometChatPinnedMessages` component lists every message pinned in a conversation, newest pin first. Pins are conversation-wide — everyone in the chat sees the same list — so the panel is a shared, always-current view of what the conversation has singled out. + +## Overview + +The Pinned Messages panel provides: + +- **Conversation-scoped list**: Pass either a `user` or a `group`; the panel fetches that conversation's pins +- **Real message bubbles**: Each row renders the actual message bubble, so media, polls, and formatted text look as they do in the chat +- **Pinned-by attribution**: Each row is labelled with who pinned it and when +- **Inline unpin**: A per-row unpin control, behind a confirmation dialog +- **Row options**: Save, Copy, Info, Translate, and Report under a three-dot menu +- **Message information**: Opens over the panel, without leaving it — Info is self-contained, so it is not forwarded to the host +- **Permission-aware**: In groups, only the owner, admins, and moderators see Unpin +- **Live updates**: Edits, deletions, reactions, and pin changes are reflected without a refetch +- **Focus trap**: Traps keyboard focus within the panel for modal-like behavior + + + Pin Message is gated by an app-level feature flag that CometChat provisions server-side. Until that flag is on, `CometChat.isPinMessageEnabled()` resolves `false` and the pin surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. + + +## Basic Usage + +### Group Pinned Messages + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { CometChatPinnedMessagesComponent } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-pinned-demo', + standalone: true, + imports: [CometChatPinnedMessagesComponent], + template: ` + + + ` +}) +export class PinnedDemoComponent { + group!: CometChat.Group; + + /** Jump the main message list to the tapped message. */ + onMessageClick(message: CometChat.BaseMessage): void { + console.log('scroll to', message.getId()); + } + + onClose(): void { + console.log('Panel closed'); + } +} +``` + +### One-on-One Pinned Messages + +Pass `user` instead of `group`. The two are mutually exclusive — set exactly one. + +```typescript expandable +@Component({ + selector: 'app-pinned-dm-demo', + standalone: true, + imports: [CometChatPinnedMessagesComponent], + template: ` + + + ` +}) +export class PinnedDmDemoComponent { + user!: CometChat.User; + + onClose(): void {} +} +``` + +### Opening the Panel from the Message Header + +`CometChatMessageHeader` can add a **Pinned messages** entry to its overflow menu. The header only asks for the panel — the host decides where it appears. + +```typescript expandable +@Component({ + selector: 'app-messages', + standalone: true, + imports: [CometChatMessageHeaderComponent, CometChatPinnedMessagesComponent], + template: ` + + + + @if (showPinned) { + + + } + ` +}) +export class MessagesComponent { + group!: CometChat.Group; + showPinned = false; + + onMessageClick(message: CometChat.BaseMessage): void {} +} +``` + +### Handling Forwarded Options + +Unpin, Save, Unsave, Copy, and Message Information are completed by the panel itself. **Translate and Report** need surfaces the panel does not own — a translation cache, a report dialog — so they are handed to the host rather than half-built here. + +```typescript expandable +@Component({ + selector: 'app-pinned-options-demo', + standalone: true, + imports: [CometChatPinnedMessagesComponent], + template: ` + + + ` +}) +export class PinnedOptionsDemoComponent { + group!: CometChat.Group; + + onOptionClick(event: { option: ContextMenuItem; message: CometChat.BaseMessage }): void { + console.log(event.option.id, event.message.getId()); + } +} +``` + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `user` | `CometChat.User` | `undefined` | Scopes the list to a one-on-one conversation. Mutually exclusive with `group` | +| `group` | `CometChat.Group` | `undefined` | Scopes the list to a group. Mutually exclusive with `user` | +| `hideCloseButton` | `boolean` | `false` | Hides the close button, for hosts that supply their own chrome | +| `headerView` | `TemplateRef` | `undefined` | Replaces the default header row | +| `emptyView` | `TemplateRef` | `undefined` | Replaces the built-in empty state | +| `errorView` | `TemplateRef` | `undefined` | Replaces the built-in error state | +| `loadingView` | `TemplateRef` | `undefined` | Replaces the built-in loading shimmer | +| `messagesRequestBuilder` | `CometChat.MessagesRequestBuilder` | `undefined` | Custom request builder. Used as supplied apart from `setPinned` and the conversation scope, which are re-asserted | +| `quickOptionsCount` | `number` | `2` | How many options sit outside the overflow menu as bare icons | +| `hideUnpinMessageOption` | `boolean` | `false` | Hides Unpin. Role gating still wins — see [Permissions](#permissions) | +| `hideSaveMessageOption` | `boolean` | `false` | Hides Save | +| `hideUnsaveMessageOption` | `boolean` | `false` | Hides Unsave | +| `hideMessageInfoOption` | `boolean` | `false` | Hides Message Information | +| `hideTranslateMessageOption` | `boolean` | `false` | Hides Translate | +| `hideCopyMessageOption` | `boolean` | `false` | Hides Copy | +| `hideFlagMessageOption` | `boolean` | `false` | Hides Report | + +## Events + +| Event | Payload Type | Description | +|-------|-------------|-------------| +| `closeClick` | `void` | Emitted when the panel close button is clicked, or Escape is pressed with no overlay open | +| `messageClick` | `CometChat.BaseMessage` | Emitted when a row is tapped. Clicks landing on a control inside the row — a menu, an audio player, a link — are not forwarded | +| `messageOptionClick` | `{ option: ContextMenuItem; message: CometChat.BaseMessage }` | Emitted for options the panel cannot complete on its own: Translate and Report. Unpin, Save, Unsave, Copy, and Message Information are handled internally and are not forwarded | +| `error` | `CometChat.CometChatException` | Emitted when fetching or unpinning fails | + +## Behavior + +### Row Options + +Options appear in a fixed order, and `quickOptionsCount` decides how many stay outside the ⋮: + +| Option | Shown when | +|--------|-----------| +| Unpin | The viewer may unpin — see [Permissions](#permissions) | +| Save / Unsave | Save Message is enabled for the app. The title and icon follow the message's own `savedAt` | +| Message Information | The message was sent by the viewer | +| Translate | The message is a text message | +| Copy | The message is a text message | +| Report | The message was sent by someone else | + +The list is read-only in every other respect: opening it marks nothing as read, moves no unread count, and nothing here edits or deletes a message. + +### Permissions + +In a group, Unpin is offered to the owner, admins, and moderators only. Ownership is read from the group's owner UID rather than the member scope, because the owner's scope arrives as `admin` on the wire. In a one-on-one conversation the option is offered and the server is the backstop. + +Unpin is deliberately **not** restricted to whoever pinned the message — anyone with the scope can remove any pin. + + + Role gating always wins over the input. Passing `[hideUnpinMessageOption]="false"` cannot grant a participant an unpin the server would reject anyway. A participant sees the panel with no Unpin option, rather than no panel. + + +### Confirmation + +Unpinning asks for confirmation; pinning does not. Pinning is trivially reversible and a dialog for it would only be friction, whereas unpinning removes something the whole conversation can see. + +### Rendering Large Lists + +The pinned read is not cursor-paginated — the server ignores `sentAt`/`id` when filtering by pinned — so the panel fetches in one request (limit `100` unless `messagesRequestBuilder` says otherwise) and windows locally: it renders 30 rows at a time and extends the window as you scroll. A conversation with hundreds of pins does not pay to build every bubble up front. + +### Live Updates + +The panel subscribes to [`CometChatPinSaveEvents`](/ui-kit/angular/events#cometchatpinsaveevents), so a pin or unpin made anywhere — by another member, or by this user on another device — is reflected without a refetch. Edits, deletions, and reactions on a pinned message update its row in place. + +## Customization + +### CSS Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `--cometchat-pinned-messages-width` | `400px` | Panel width | +| `--cometchat-pinned-messages-height` | `100%` | Panel height | +| `--cometchat-pinned-messages-background` | `--cometchat-background-color-01` | Panel background | +| `--cometchat-pinned-messages-border` | `1px solid --cometchat-border-color-light` | Leading edge border | +| `--cometchat-pinned-messages-header-padding` | `12px 16px` | Header padding | +| `--cometchat-pinned-messages-title-font` | `--cometchat-font-heading4-medium` | Header title font | +| `--cometchat-pinned-messages-item-padding` | `8px 16px` | Row padding | +| `--cometchat-pinned-messages-item-background-hover` | `--cometchat-background-color-02` | Row hover background | +| `--cometchat-pinned-messages-entry-name-font` | `--cometchat-font-caption1-medium` | "Pinned by" name font | +| `--cometchat-pinned-messages-entry-date-font` | `--cometchat-font-caption1-regular` | Pin timestamp font | +| `--cometchat-pinned-messages-empty-icon-size` | `120px` | Empty-state illustration size | +| `--cometchat-pinned-messages-empty-title-font` | `--cometchat-font-heading4-bold` | Empty-state headline font | +| `--cometchat-pinned-messages-empty-subtitle-gap` | `--cometchat-margin-2` | Gap between headline and explanation | +| `--cometchat-pinned-messages-info-panel-width` | `90%` | Message-information overlay width | +| `--cometchat-pinned-messages-info-panel-max-width` | `420px` | Message-information overlay maximum width | +| `--cometchat-pinned-messages-info-panel-shadow` | `--cometchat-shadow-sm` | Message-information overlay shadow | +| `--cometchat-pinned-messages-dialog-overlay-background` | `--cometchat-overlay-background` | Confirmation dialog scrim | + +## Accessibility + +### Keyboard Navigation + +- **Escape** dismisses the topmost layer only: the information overlay first, then the confirmation dialog, then the panel itself +- **Tab** cycles within the panel; focus does not escape to the page behind it +- **Enter** / **Space** on a row activates it, as a click does + +### Focus Management + +Focus is trapped on open and released on destroy, so the panel behaves as a modal surface while it is up. + +### Screen Reader Support + +- The panel is a labelled `region` +- Rows are exposed as buttons +- Loading, empty, and error states are announced via `role="status"` +- Decorative glyphs — pin markers, media icons, illustrations — are `aria-hidden` + +## Related + +- [Pin and Save Messages](/ui-kit/angular/guides/pin-and-save-messages) — the feature guide, including how to enable it +- [CometChatSavedMessages](/ui-kit/angular/components/cometchat-saved-messages) — the per-user counterpart +- [CometChatMessageInformation](/ui-kit/angular/components/cometchat-message-information) — opened from a row's Info option +- [CometChatMessageList](/ui-kit/angular/components/cometchat-message-list) — where messages are pinned from diff --git a/ui-kit/angular/components/cometchat-saved-messages.mdx b/ui-kit/angular/components/cometchat-saved-messages.mdx new file mode 100644 index 000000000..bb338f54a --- /dev/null +++ b/ui-kit/angular/components/cometchat-saved-messages.mdx @@ -0,0 +1,186 @@ +--- +title: "Saved Messages" +description: "A panel component listing the messages the logged-in user has saved, across every conversation" +--- + +The `CometChatSavedMessages` component lists the messages the logged-in user has saved, newest first, gathered from every conversation they take part in. Saving is private: a saved message is visible only to the user who saved it, and no one else in the conversation is told. + +## Overview + +The Saved Messages panel provides: + +- **Cross-conversation list**: Every save the user has made, regardless of which chat it came from +- **Conversation-style rows**: Each row shows the source conversation's avatar and name, with the speaker named in the subtitle — the same shape the conversation list uses +- **Rich previews**: Media messages name their type behind a matching icon, captions win over type labels, and thread replies are marked +- **Inline unsave**: A per-row unsave control, behind a confirmation dialog +- **Paged loading**: 30 rows per page, fetching the next page as you scroll +- **Live updates**: Saves and unsaves made elsewhere in the app are reflected without a refetch +- **Focus trap**: Traps keyboard focus within the panel for modal-like behavior + + + Save Message is gated by an app-level feature flag that CometChat provisions server-side. Until that flag is on, `CometChat.isSaveMessageEnabled()` resolves `false` and the save surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. + + +## Basic Usage + +### Simple Saved Messages Panel + +The panel is scoped to the logged-in user, so it takes no conversation input. Because a save spans every conversation, place it in your app chrome rather than in a chat header. + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { CometChatSavedMessagesComponent } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-saved-demo', + standalone: true, + imports: [CometChatSavedMessagesComponent], + template: ` + + + ` +}) +export class SavedDemoComponent { + /** Open the source conversation and jump to the message. */ + onMessageClick(message: CometChat.BaseMessage): void { + console.log('open', message.getConversationId?.(), message.getId()); + } + + onClose(): void { + console.log('Panel closed'); + } +} +``` + +### Read-Only Panel + +Hide the unsave control when the panel is used purely for navigation. + +```typescript expandable +@Component({ + selector: 'app-saved-readonly-demo', + standalone: true, + imports: [CometChatSavedMessagesComponent], + template: ` + + + ` +}) +export class SavedReadonlyDemoComponent { + onMessageClick(message: CometChat.BaseMessage): void {} +} +``` + +### Custom Empty State + +```typescript expandable +@Component({ + selector: 'app-saved-empty-demo', + standalone: true, + imports: [CometChatSavedMessagesComponent], + template: ` + + + +
Nothing saved yet — tap Save on any message.
+
+ ` +}) +export class SavedEmptyDemoComponent {} +``` + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `hideUnsaveMessageOption` | `boolean` | `false` | Hides the per-row unsave control, making the list read-only | +| `hideCloseButton` | `boolean` | `false` | Hides the close button, for hosts that supply their own chrome | +| `headerView` | `TemplateRef` | `undefined` | Replaces the default header row | +| `emptyView` | `TemplateRef` | `undefined` | Replaces the built-in empty state | +| `errorView` | `TemplateRef` | `undefined` | Replaces the built-in error state | +| `loadingView` | `TemplateRef` | `undefined` | Replaces the built-in loading shimmer | +| `messagesRequestBuilder` | `CometChat.MessagesRequestBuilder` | `undefined` | Custom request builder. Used as supplied apart from `setSaved`, which is re-asserted. Do not scope it to a UID/GUID — saves span conversations | + +## Events + +| Event | Payload Type | Description | +|-------|-------------|-------------| +| `closeClick` | `void` | Emitted when the panel close button is clicked, or Escape is pressed with no dialog open | +| `messageClick` | `CometChat.BaseMessage` | Emitted when a row is tapped. The host opens the source conversation and jumps to the message | +| `error` | `CometChat.CometChatException` | Emitted when fetching or unsaving fails | + +## Behavior + +### Privacy + +`savedAt` is per-viewer: it is only ever populated on the acting user's own copy of a message. Saving a message therefore tells no one, and this panel never shows another user's saves. Save events arrive only on the acting user's own devices, which is what keeps a save in sync across their sessions. + +### Row Identity + +A saved message is shown under the conversation it came from — a group's name and icon, or the other party in a one-on-one — with the speaker named in the subtitle. Source names are resolved once and cached, and the raw ID shows until a name arrives, so a row is never withheld waiting on it. + +### Confirmation + +Unsaving asks for confirmation; saving does not. + +### Pagination + +The first page is 30 rows and the next is fetched as the list nears its end. A page that yields nothing new stops further loading, so a repeated page cannot spin. + +## Customization + +### CSS Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `--cometchat-saved-messages-width` | `400px` | Panel width | +| `--cometchat-saved-messages-height` | `100%` | Panel height | +| `--cometchat-saved-messages-background` | `--cometchat-background-color-01` | Panel background | +| `--cometchat-saved-messages-border` | `1px solid --cometchat-border-color-light` | Leading edge border | +| `--cometchat-saved-messages-header-padding` | `12px 16px` | Header padding | +| `--cometchat-saved-messages-title-font` | `--cometchat-font-heading4-medium` | Header title font | +| `--cometchat-saved-messages-avatar-size` | `48px` | Row avatar size | +| `--cometchat-saved-messages-row-padding` | `8px 16px` | Row padding | +| `--cometchat-saved-messages-row-gap` | `--cometchat-spacing-3` | Gap between avatar, body, and trailing control | +| `--cometchat-saved-messages-row-background-hover` | `--cometchat-background-color-02` | Row hover background | +| `--cometchat-saved-messages-row-title-font` | `--cometchat-font-heading4-medium` | Source conversation name font | +| `--cometchat-saved-messages-row-sender-font` | `--cometchat-font-body-medium` | Sender prefix font | +| `--cometchat-saved-messages-row-preview-font` | `--cometchat-font-body-regular` | Preview text font | +| `--cometchat-saved-messages-preview-icon-size` | `16px` | Media-type icon size | +| `--cometchat-saved-messages-preview-icon-color` | `--cometchat-icon-color-secondary` | Media-type icon color | +| `--cometchat-saved-messages-thread-icon-size` | `12px` | Thread-reply marker size | +| `--cometchat-saved-messages-thread-icon-opacity` | `0.7` | Thread-reply marker opacity | +| `--cometchat-saved-messages-empty-icon-width` | `65px` | Empty-state illustration width | +| `--cometchat-saved-messages-empty-subtitle-gap` | `--cometchat-margin-2` | Gap between headline and explanation | +| `--cometchat-saved-messages-dialog-overlay-background` | `--cometchat-overlay-background` | Confirmation dialog scrim | + +## Accessibility + +### Keyboard Navigation + +- **Escape** dismisses the confirmation dialog if one is open, otherwise the panel +- **Tab** cycles within the panel; focus does not escape to the page behind it +- **Enter** / **Space** on a row activates it, as a click does + +### Focus Management + +Focus is trapped on open and released on destroy, so the panel behaves as a modal surface while it is up. + +### Screen Reader Support + +- The panel is a labelled `region` +- Rows are exposed as buttons +- Loading, empty, and error states are announced via `role="status"` +- Media-type icons, thread markers, and illustrations are `aria-hidden`; the preview text carries the meaning + +## Related + +- [Pin and Save Messages](/ui-kit/angular/guides/pin-and-save-messages) — the feature guide, including how to enable it +- [CometChatPinnedMessages](/ui-kit/angular/components/cometchat-pinned-messages) — the conversation-wide counterpart +- [CometChatConversations](/ui-kit/angular/components/cometchat-conversations) — whose row shape this panel follows +- [CometChatMessageList](/ui-kit/angular/components/cometchat-message-list) — where messages are saved from diff --git a/ui-kit/angular/components/cometchat-thread-header.mdx b/ui-kit/angular/components/cometchat-thread-header.mdx index f4a4cd6ba..9cbcbd92a 100644 --- a/ui-kit/angular/components/cometchat-thread-header.mdx +++ b/ui-kit/angular/components/cometchat-thread-header.mdx @@ -12,6 +12,7 @@ The Thread Header component provides: - **Media Icons**: Displays type-specific icons for image, video, audio, and file messages - **Reply Count**: Shows the number of replies with singular/plural localization - **Close Button**: Returns the user to the main chat view +- **Follow / Unfollow Control**: An icon-only bell that subscribes the user to the thread's replies, when the feature is enabled - **Keyboard Navigation**: Supports Tab, Enter, Space, and Escape key interactions - **Full ARIA Support**: Includes `role="banner"`, descriptive labels, and accessible close button @@ -153,18 +154,69 @@ export class ThreadHeaderPropsComponent { +## Thread Subscription + +When thread subscription is enabled, the header's top bar carries an icon-only bell that follows or unfollows the thread. Following opts the user into notifications for the thread's replies; it is per-user and changes nothing anyone else sees. + +The control renders only when **all** of the following hold: + +1. `enableThreadSubscription: true` is provided through [`COMETCHAT_GLOBAL_CONFIG`](/ui-kit/angular/customization/global-config#enablethreadsubscription) — the feature is **off by default** +2. The installed Chat SDK exposes the thread API +3. `hideThreadSubscriptionToggle` is `false` +4. The server has not reported the thread as deleted or off-limits + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { + CometChatThreadHeaderComponent, + IThreadSubscriptionChange, +} from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-thread-header-subscription', + standalone: true, + imports: [CometChatThreadHeaderComponent], + template: ` + + + `, +}) +export class ThreadHeaderSubscriptionComponent { + parentMessage!: CometChat.BaseMessage; + + onSubscriptionChange(event: IThreadSubscriptionChange): void { + console.log(event.parentMessageId, event.state); + } + + onClose(): void {} +} +``` + + + The label is **state-labelled** here ("Mute thread" / "Unmute thread"), unlike the action-sheet option in `cometchat-message-list`, which is action-labelled. A top-bar icon reads as a state; a menu row reads as what tapping it does. + + +The control stays enabled in every state, including an unknown one — a disabled control on a deep-linked thread is a dead end, and following something you already follow is idempotent server-side. See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) for the full feature. + ## Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `parentMessage` | `CometChat.BaseMessage` | **required** | The parent message of the thread. Used to display the message preview and media icon | | `replyCount` | `number` | `0` | The number of replies in the thread. Displayed with localized singular/plural text. When not provided, the count is automatically read from `parentMessage.getReplyCount()` | +| `hideThreadSubscriptionToggle` | `boolean` | `false` | Hides the follow/unfollow control without turning the feature off — for apps that want the action-sheet entry point only | +| `trailingView` | `TemplateRef` | `undefined` | Renders in the top-bar action row **in place of** the follow control. The close button is unaffected and still renders after it | ## Events | Event | Payload Type | Description | |-------|-------------|-------------| | `closeClick` | `void` | Emitted when the close button is clicked, Enter/Space is pressed on the close button, or Escape is pressed while the header is focused | +| `threadSubscriptionChange` | `IThreadSubscriptionChange` | Emitted on every change to this thread's subscription, whoever caused it — this user's tap, another surface, or a server-side auto-subscribe. Payload: `{ parentMessageId: number; state: CometChat.ThreadSubscriptionState }` | | `backClick` | `void` | **Deprecated.** Use `closeClick` instead. Emitted alongside `closeClick` for backward compatibility | ## Customization @@ -210,8 +262,35 @@ The Thread Header component uses BEM-style CSS classes with CSS variable overrid .cometchat-thread-header__media-icon { background-color: var(--cometchat-thread-header-media-icon-color, var(--cometchat-icon-color-secondary)); } + +/* Top-bar action row holding the follow control and close button */ +.cometchat-thread-header__top-bar-actions { + gap: var(--cometchat-thread-header-top-bar-actions-gap, var(--cometchat-spacing-1)); +} + +/* Follow / unfollow control */ +.cometchat-thread-header__subscription-button { + width: var(--cometchat-thread-header-subscription-button-width, 32px); + height: var(--cometchat-thread-header-subscription-button-height, 32px); + border-radius: var(--cometchat-thread-header-subscription-button-border-radius, var(--cometchat-radius-max)); + background: var(--cometchat-thread-header-subscription-button-background, transparent); +} + +.cometchat-thread-header__subscription-button:hover { + background: var(--cometchat-thread-header-subscription-button-background-hover, var(--cometchat-background-color-04)); +} + +.cometchat-thread-header__subscription-icon { + width: var(--cometchat-thread-header-subscription-icon-width, 24px); + height: var(--cometchat-thread-header-subscription-icon-height, 24px); + background-color: var(--cometchat-thread-header-subscription-icon-color, var(--cometchat-icon-color-primary)); +} ``` + + Both subscription states share the same neutral icon color — the slash through the bell is what distinguishes them, not a tint. State is also carried by `aria-label` and `aria-pressed`, so nothing rests on color alone. + + ### Responsive Behavior The component adapts across breakpoints with dedicated CSS variables for tablet (`max-width: 991px`), mobile (`max-width: 767px`), and small mobile (`max-width: 575px`) — reducing padding, font sizes, and icon dimensions at smaller widths. @@ -220,8 +299,8 @@ The component adapts across breakpoints with dedicated CSS variables for tablet ### Keyboard Navigation -- **Tab**: Moves focus to the close button -- **Enter / Space**: Activates the close button when focused +- **Tab**: Moves focus through the follow control and the close button +- **Enter / Space**: Activates the focused control - **Escape**: Closes the thread from anywhere within the header (via `@HostListener`) ### Screen Reader Support @@ -229,13 +308,17 @@ The component adapts across breakpoints with dedicated CSS variables for tablet - The header uses `role="banner"` for landmark navigation - An `aria-label` combines the parent message preview and reply count for context - The close button has a dedicated `aria-label` (e.g., "Close thread") +- The follow control exposes `aria-pressed` for its state, and its tooltip and accessible name are the **same string**, so a voice-control user can say what the tooltip showed them (WCAG 2.5.3) +- Toggling announces the resulting state through a live region, rather than re-reading the button label — the label names the *next* action, which reads backwards once the state has changed ### High Contrast & Reduced Motion - Supports `prefers-contrast: high` with stronger borders and focus outlines - Supports `prefers-reduced-motion: reduce` by disabling button transitions -## Related Components +## Related +- [Thread Subscription](/ui-kit/angular/guides/thread-subscription) - Following and unfollowing threads, end to end +- [Threaded Messages](/ui-kit/angular/guides/threaded-messages) - Building the thread view itself - [CometChatMessageList](/ui-kit/angular/components/cometchat-message-list) - Displays messages within the thread - [CometChatMessageBubble](/ui-kit/angular/components/cometchat-message-bubble) - Renders individual messages in the thread diff --git a/ui-kit/angular/components/components-overview.mdx b/ui-kit/angular/components/components-overview.mdx index 47d44849e..2d72bc7db 100644 --- a/ui-kit/angular/components/components-overview.mdx +++ b/ui-kit/angular/components/components-overview.mdx @@ -25,6 +25,8 @@ Components for displaying and sending messages. | `cometchat-message-header` | Header showing conversation info and actions | | `cometchat-message-bubble` | Individual message bubble | | `cometchat-thread-header` | Header for threaded message view | +| `cometchat-pinned-messages` | Panel listing a conversation's pinned messages | +| `cometchat-saved-messages` | Panel listing the logged-in user's saved messages | ### Users @@ -159,6 +161,8 @@ All components are imported from `@cometchat/chat-uikit-angular`. | cometchat-message-list | Scrollable message list with reactions, receipts, threads | `user`, `group`, `messagesRequestBuilder` | [Message List](/ui-kit/angular/components/cometchat-message-list) | | cometchat-message-composer | Rich text input with attachments, mentions, voice notes | `user`, `group`, `placeholderText` | [Message Composer](/ui-kit/angular/components/cometchat-message-composer) | | cometchat-thread-header | Parent message bubble and reply count for threaded view | `parentMessage` | [Thread Header](/ui-kit/angular/components/cometchat-thread-header) | +| cometchat-pinned-messages | A conversation's pinned messages, with attribution and inline unpin | `user`, `group` | [Pinned Messages](/ui-kit/angular/components/cometchat-pinned-messages) | +| cometchat-saved-messages | The logged-in user's saved messages, across every conversation | *(none — scoped to the logged-in user)* | [Saved Messages](/ui-kit/angular/components/cometchat-saved-messages) | ### Calling diff --git a/ui-kit/angular/core-features.mdx b/ui-kit/angular/core-features.mdx index f132273e7..aab782ab4 100644 --- a/ui-kit/angular/core-features.mdx +++ b/ui-kit/angular/core-features.mdx @@ -131,6 +131,32 @@ Threaded Conversations enable users to respond directly to a specific message, k | [cometchat-message-list](/ui-kit/angular/components/cometchat-message-list) | Renders thread replies when `parentMessageId` is set. | | [cometchat-message-composer](/ui-kit/angular/components/cometchat-message-composer) | Sends replies within a thread when `parentMessageId` is set. | +## Thread Subscription + +Thread Subscription lets a user follow one specific thread so they are notified about its replies, and unfollow when they no longer want them. It is per-user and per-thread, and is **off by default** — enable it with `enableThreadSubscription` in [Global Configuration](/ui-kit/angular/customization/global-config#enablethreadsubscription). + +| Component | Functionality | +| --- | --- | +| [cometchat-thread-header](/ui-kit/angular/components/cometchat-thread-header) | Shows an icon-only follow/unfollow control in the thread's top bar. | +| [cometchat-message-list](/ui-kit/angular/components/cometchat-message-list) | Adds a follow/unfollow option to the message action menu, right after "Reply in thread". | + +See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription). + +## Pinned and Saved Messages + +Pinning highlights a message for **everyone** in a conversation; saving bookmarks one privately for the acting user, across every conversation. Both are gated by app-level feature flags provisioned by CometChat. + +| Component | Functionality | +| --- | --- | +| [cometchat-message-list](/ui-kit/angular/components/cometchat-message-list) | Offers Pin, Unpin, Save, and Unsave under an "Organise" flyout in the message action menu. | +| [cometchat-message-bubble](/ui-kit/angular/components/cometchat-message-bubble) | Marks pinned and saved messages in the bubble's status-info footer. | +| [cometchat-pinned-messages](/ui-kit/angular/components/cometchat-pinned-messages) | Lists a conversation's pinned messages, with attribution and inline unpin. | +| [cometchat-saved-messages](/ui-kit/angular/components/cometchat-saved-messages) | Lists the logged-in user's saved messages across every conversation. | +| [cometchat-message-header](/ui-kit/angular/components/cometchat-message-header) | Adds a "Pinned messages" entry to its overflow menu via `showPinnedMessagesOption`. | +| [cometchat-conversations](/ui-kit/angular/components/cometchat-conversations) | Pins a conversation to the top of the user's list from the row context menu. | + +See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages). + ## Quoted Replies Quoted Replies enable 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 one-on-one and group chats. diff --git a/ui-kit/angular/customization/global-config.mdx b/ui-kit/angular/customization/global-config.mdx index 17cfe3652..d99b4998c 100644 --- a/ui-kit/angular/customization/global-config.mdx +++ b/ui-kit/angular/customization/global-config.mdx @@ -93,12 +93,54 @@ import { COMETCHAT_GLOBAL_CONFIG } from '@cometchat/chat-uikit-angular'; | `customSoundForCalls` | `string` | `undefined` | Custom sound URL for calls | | `customSoundForMessages` | `string` | `undefined` | Custom sound URL for messages | | `hideAvatar` | `boolean` | `undefined` | Hide avatar across components | +| `enableThreadSubscription` | `boolean` | `undefined` (off) | Enable the thread follow/unfollow surfaces — the control in the thread header and the follow option in the message action sheet | +| `enablePinMessage` | `boolean` | `undefined` | Force the Pin Message surfaces on or off, overriding the app-level flag the Chat SDK reports | +| `enableSaveMessage` | `boolean` | `undefined` | Force the Save Message surfaces on or off, overriding the app-level flag the Chat SDK reports | | `callSettingsBuilder` | `CallSettingsBuilder` | `undefined` | Custom `CallSettingsBuilder` for call components (call buttons, call logs, ongoing call) | All properties are optional. When a property is `undefined` (not set), the component falls back to its own internal default. +## Feature Gates + +Three properties gate whole features rather than tune a component's appearance. They behave differently from the rest of the table, so they are worth calling out. + +### enableThreadSubscription + +Defaults to **off**. The backend exposes no capability flag a client could feature-detect from, so whether the threads endpoints are deployed is something only you know. When off, neither the thread header control nor the action-sheet option renders and no thread request is made — whatever the per-component `hideThreadSubscription*` inputs say. + +```typescript expandable +{ + provide: COMETCHAT_GLOBAL_CONFIG, + useValue: { + enableThreadSubscription: true, + } as GlobalConfig, +} +``` + +See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription). + +### enablePinMessage and enableSaveMessage + +These **override** the app-level feature flags the Chat SDK reports through `CometChat.isPinMessageEnabled()` and `CometChat.isSaveMessageEnabled()`. An explicit value wins; leaving a property unset falls back to the SDK's answer. + +```typescript expandable +{ + provide: COMETCHAT_GLOBAL_CONFIG, + useValue: { + enablePinMessage: true, + enableSaveMessage: true, + } as GlobalConfig, +} +``` + + + Leave both unset in production. The app's own settings are the right source of truth. These exist because the flags are provisioned server-side — so before they are switched on for an app, "flag off" is indistinguishable from "not built" and there is no way to develop or demo against the feature. + + +See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages). + ## Call Settings Customization The `callSettingsBuilder` property lets you provide a custom `CometChatCalls.CallSettingsBuilder` that is used across all call-related components. This is useful for configuring call UI features like layout, recording, analytics, or audio-only mode globally. @@ -159,7 +201,8 @@ The following components read from `COMETCHAT_GLOBAL_CONFIG` and apply the three |-----------|----------------| | CometChatConversations | `hideReceipts`, `hideError`, `hideUserStatus`, `hideGroupType`, `showScrollbar`, `showSearchBar`, `disableSoundForMessages`, `textFormatters`, `customSoundForMessages`, `hideAvatar`, `disableDefaultContextMenu` | | CometChatMessageHeader | `hideUserStatus` | -| CometChatMessageList | `hideReceipts`, `hideError`, `textFormatters`, `disableSoundForMessages`, `customSoundForMessages`, `disableDefaultContextMenu` | +| CometChatMessageList | `hideReceipts`, `hideError`, `textFormatters`, `disableSoundForMessages`, `customSoundForMessages`, `disableDefaultContextMenu`, `enableThreadSubscription`, `enablePinMessage`, `enableSaveMessage` | +| CometChatThreadHeader | `enableThreadSubscription` | | CometChatMessageComposer | `textFormatters`, `disableSoundForMessages`, `customSoundForMessages` | | CometChatCallButtons | `disableSoundForCalls`, `customSoundForCalls`, `callSettingsBuilder` | | CometChatCallLogs | `showScrollbar`, `callSettingsBuilder` | diff --git a/ui-kit/angular/events.mdx b/ui-kit/angular/events.mdx index 2f93a73f7..90b0b2dde 100644 --- a/ui-kit/angular/events.mdx +++ b/ui-kit/angular/events.mdx @@ -71,6 +71,54 @@ Events provide decoupled communication between UIKit components using a publish/ | **onTransientMessageReceived** | Emitted when the CometChat SDK listener receives a transient message. | | **onCardMessageReceived** | Emitted when the CometChat SDK listener receives a developer card message (`category: "card"`). Carries a `CometChat.CardMessage`. The UIKit renders cards but never sends or creates them. | +## CometChatThreadEvents + +`CometChatThreadEvents` emits events when this client's view of a thread's subscription changes — whether the change was made here, on another device, or by the server. + +| Event Name | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------ | +| **ccThreadSubscriptionChanged** | Triggered when a thread is followed or unfollowed. Also fires for the auto-subscribe the backend performs when the user replies to a thread they were not following. | + +**Payload (`IThreadSubscriptionChanged`)** + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `parentMessageId` | `number` | The root message ID of the thread whose state changed. | +| `state` | `CometChat.ThreadSubscriptionState` | `SUBSCRIBED`, `NOT_SUBSCRIBED`, or `UNKNOWN`. | +| `source` | `string` | `"local"` for an optimistic flip not yet acked; `"sdk"` for a confirmed write or a server-initiated change. Treat it as an open string — an unrecognised value must never break a subscriber. | + +This is the channel that keeps the thread header control and the message action sheet in agreement without a refetch. It is also the channel to subscribe to if you build your own threads list against `CometChat.ThreadsRequest`. + + + Unfollowing hard-deletes the thread-list row server-side. A list of your own must **remove** the row rather than re-render it in an "unfollowed" style. + + +See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) for the full feature. + +## CometChatPinSaveEvents + +`CometChatPinSaveEvents` emits events when a message is pinned, unpinned, saved, or unsaved. + +| Event Name | Reach | Description | +| ---------------------- | ------------------------ | ------------------------------------------------------------------------ | +| **ccMessagePinned** | Broadcast | Triggered when a message is pinned. A pin is conversation-wide, so everyone in the conversation receives it. | +| **ccMessageUnpinned** | Broadcast | Triggered when a message is unpinned. | +| **ccMessageSaved** | Private, multi-device | Triggered when the logged-in user saves a message. A save is per-user, so this arrives only on that user's own devices. | +| **ccMessageUnsaved** | Private, multi-device | Triggered when the logged-in user unsaves a message. | + +**Payload (`IPinSaveChanged`)** + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `message` | `CometChat.BaseMessage` | The full updated message. | +| `source` | `string` | `"optimistic"` for a local flip not yet acked, `"revert"` when a failure put it back, `"sdk"` for a confirmed write or a realtime frame. Open string — handle unknown values gracefully. | + + + Each payload carries the **full updated message**, so swap your copy wholesale rather than patching fields. `pinnedAt` and `savedAt` are present-only-when-set and are cleared — never zeroed — on unpin and unsave, so a partial patch leaves a stale timestamp behind and the indicator never disappears. + + +See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages) for the full feature. + ## CometChatCallEvents `CometChatCallEvents` emits events when the logged-in user acts on a call object. diff --git a/ui-kit/angular/guides/guides-overview.mdx b/ui-kit/angular/guides/guides-overview.mdx index 2dea5a2c2..adabcd324 100644 --- a/ui-kit/angular/guides/guides-overview.mdx +++ b/ui-kit/angular/guides/guides-overview.mdx @@ -27,6 +27,8 @@ Use these guides after completing the base [Integration Guide](/ui-kit/angular/i | Guide | Description | |:------|:------------| | [Threaded Messages](/ui-kit/angular/guides/threaded-messages) | Implement threaded message replies with parent context, reply list, and focused thread composer. | +| [Thread Subscription](/ui-kit/angular/guides/thread-subscription) | Let users follow and unfollow individual threads so they are notified about new replies. | +| [Pin & Save](/ui-kit/angular/guides/pin-and-save-messages) | Pin messages for the whole conversation, save messages privately, and pin conversations to the top of the list. | | [Group Chat](/ui-kit/angular/guides/group-chat) | Create and join groups, view members, manage roles and scopes, transfer ownership. | | [New Chat](/ui-kit/angular/guides/new-chat) | Start new one-to-one or group conversations with user and group discovery. | | [Search Messages](/ui-kit/angular/guides/threaded-messages) | Add full-text message search across conversations with result routing. | diff --git a/ui-kit/angular/guides/pin-and-save-messages.mdx b/ui-kit/angular/guides/pin-and-save-messages.mdx new file mode 100644 index 000000000..f45cfb4f4 --- /dev/null +++ b/ui-kit/angular/guides/pin-and-save-messages.mdx @@ -0,0 +1,350 @@ +--- +title: "Pin and Save Messages" +sidebarTitle: "Pin & Save" +description: "Pin messages for everyone in a conversation, save messages privately, and pin conversations to the top of the list with the CometChat Angular UIKit." +--- + + + +| Field | Value | +| --- | --- | +| Package | `@cometchat/chat-uikit-angular` | +| Key components | `cometchat-message-list`, `cometchat-pinned-messages`, `cometchat-saved-messages`, `cometchat-conversations` | +| Feature gate | App-level flags (`CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()`), overridable via `COMETCHAT_GLOBAL_CONFIG` | +| Service | `PinSaveService` | +| Events | `CometChatPinSaveEvents` — `ccMessagePinned`, `ccMessageUnpinned`, `ccMessageSaved`, `ccMessageUnsaved` | +| State | `message.getPinnedAt()`, `getPinnedBy()`, `getSavedAt()`; `conversation.isPinned()` | +| Related | [Pinned Messages](/ui-kit/angular/components/cometchat-pinned-messages), [Saved Messages](/ui-kit/angular/components/cometchat-saved-messages), [All Guides](/ui-kit/angular/guides/guides-overview) | + + + +The UI Kit ships three related but distinct actions: + +| Action | Scope | Who sees it | +|:---|:---|:---| +| **Pin message** | One conversation | Everyone in the conversation | +| **Save message** | The acting user, across every conversation | Only that user | +| **Pin conversation** | The acting user's conversation list | Only that user | + +That split drives everything below: a pin is a shared act and is permission-gated, while a save is private and is not. + +Before starting, complete the [Integration Guide](/ui-kit/angular/integration). + +--- + +## Enabling the Feature + +Pin Message and Save Message are gated by **app-level feature flags** that CometChat provisions server-side. The UI Kit resolves them once per session through the Chat SDK: + +- `CometChat.isPinMessageEnabled()` +- `CometChat.isSaveMessageEnabled()` + +While a flag is off, the SDK reports the feature disabled and none of the pin or save surfaces render. + +### Development override + +Because the flags are provisioned server-side, "flag off" is indistinguishable from "not built" during development. `COMETCHAT_GLOBAL_CONFIG` can force either surface on or off: + +```typescript expandable +import { ApplicationConfig } from '@angular/core'; +import { COMETCHAT_GLOBAL_CONFIG, GlobalConfig } from '@cometchat/chat-uikit-angular'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: COMETCHAT_GLOBAL_CONFIG, + useValue: { + enablePinMessage: true, + enableSaveMessage: true, + } as GlobalConfig, + }, + ], +}; +``` + + + Leave both unset in production. The SDK's app-level flags are the right source of truth; an explicit `true` here shows options the backend may still reject. + + +Pinning a **conversation** has no such flag. It renders whenever the installed Chat SDK exposes `pinConversation` / `unpinConversation`, and is hidden with `[hidePinConversation]="true"`. + +--- + +## Pinning and Saving a Message + +Both actions live in the message context menu, gathered under an **Organise ▸** flyout so two related actions cost one row in an already long menu. The flyout is omitted entirely when neither action applies. + +```html expandable + + +``` + +| Input | Default | Hides | +|:---|:---|:---| +| `hidePinMessageOption` | `false` | **Pin message** on an unpinned message | +| `hideUnpinMessageOption` | `false` | **Unpin message** on a pinned one | +| `hideSaveMessageOption` | `false` | **Save message** on an unsaved message | +| `hideUnsaveMessageOption` | `false` | **Unsave message** on a saved one | + +The menu shows Pin **or** Unpin — never a toggling third state — because the presence of `pinnedAt` *is* the boolean. The same holds for `savedAt`. + +### Eligibility + +A message can carry a pin or a save unless it is deleted, still in flight, held by moderation, or an `action` category message ("X joined the group" is not something anyone pins). Thread replies **are** eligible: the backend accepts them and returns the parent for context, so the option belongs inside the thread view too. + +### Permissions + +Pinning is conversation-wide, so it is scope-gated the same way deleting someone else's message is: in a group, a known **participant** is denied and everyone else is offered the option. In a one-on-one there is no scope to check, so the client assumes it is allowed and the server is the backstop — a refusal returns `ERR_ACTION_NOT_ALLOWED`, and the optimistic flip reverts with an explanatory toast. + +Saving has no scope gate at all: it is private to the acting user and changes nothing anyone else can see. + + + The client denies only a *known* participant rather than allow-listing the elevated scopes. `getScope()` is a membership property and is frequently undefined on a `Group` derived from a conversation, so an allow-list would hide Pin from admins whose scope simply has not loaded yet. + + +### Confirmation + +Pinning and saving run immediately. **Unpinning and unsaving ask first** — unpinning acts for everyone in the chat, and an unsave can drop the only pointer the user had to a message buried far up the history. + +### Limits + +The server owns the pin and save caps. When one is hit, the rejection carries the limit and the toast names the real number ("You can pin up to 5 messages in a conversation."), so the copy never goes stale when the backend retunes the cap. + +--- + +## Indicators + +Once a message is pinned or saved, the bubble marks it in its status-info footer: + +- **Pin marker** — shown to everyone, because `pinnedAt` is conversation-wide +- **Bookmark marker** — shown only to the user who saved it; `savedAt` is simply not present in anyone else's copy + +A batched media message normally shows a footer on its last bubble only. A pinned or saved bubble keeps its footer regardless, since hiding it would hide the very badge that explains the mark. + +```css expandable +/* Both markers */ +.cometchat-message-bubble__pinned-indicator, +.cometchat-message-bubble__saved-indicator { + background-color: var(--cometchat-message-bubble-meta-indicator-color, var(--cometchat-text-color-secondary)); +} + +/* Outgoing bubbles invert them */ +.cometchat-message-bubble__wrapper--outgoing .cometchat-message-bubble__pinned-indicator { + background-color: var(--cometchat-message-bubble-meta-indicator-color-outgoing, var(--cometchat-text-color-white)); +} +``` + +--- + +## Viewing Pinned and Saved Messages + +Two panels list what has been marked. They are separate components — see their own pages for the full API. + +### Pinned messages + +Conversation-scoped, shared, and permission-aware. Add the entry point to the message header: + +```html expandable + + + +@if (showPinnedPanel) { + + +} +``` + +See [CometChatPinnedMessages](/ui-kit/angular/components/cometchat-pinned-messages). + +### Saved messages + +Per-user and cross-conversation, so it belongs in your app chrome rather than in a chat header — hanging it off one conversation would misrepresent what it contains. + +```html expandable + + +``` + +See [CometChatSavedMessages](/ui-kit/angular/components/cometchat-saved-messages). + +--- + +## Pinning a Conversation + +`CometChatConversations` adds a **Pin conversation** / **Unpin conversation** entry to each row's context menu, above Delete — the safe, reversible action comes first. + +```html expandable + + +``` + +### Ordering + +Pinned conversations are lifted above unpinned ones. The partition is **stable**: recency still decides the order among pinned chats and among unpinned ones, so a pinned chat with a new message still rises to the top of its block, and one message in an unpinned chat can never push the pinned block down. + +### Reading the state + +```typescript +const isPinned = conversation.isPinned?.(); +``` + +The presence of `pinnedAt` is the boolean here too — an unpinned conversation carries no key at all. `isPinned()` covers both a personal pin and an app-wide one (`pinnedBy === "app_system"`). + +### Behavior + +- Pinning runs straight away; **unpinning asks for confirmation**, because a pin is a deliberate arrangement of the list and a misplaced click should not undo it +- The row shows a pin marker, and the state reaches screen readers through the row's accessible label, since the marker itself is `aria-hidden` +- The pin cap is server-owned; exceeding it shows "You can pin up to N conversations." with the real number from the rejection + +--- + +## Reacting to Changes + +`CometChatPinSaveEvents` is the channel that keeps bubbles, both panels, and any surface of your own in agreement without a refetch. + +| Event | Reach | Description | +|:---|:---|:---| +| `ccMessagePinned` | Broadcast | A message was pinned — everyone in the conversation hears it | +| `ccMessageUnpinned` | Broadcast | A message was unpinned | +| `ccMessageSaved` | Private, multi-device | The logged-in user saved a message | +| `ccMessageUnsaved` | Private, multi-device | The logged-in user unsaved a message | + +```typescript expandable +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CometChatPinSaveEvents } from '@cometchat/chat-uikit-angular'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-pin-listener', + standalone: true, + template: ``, +}) +export class PinListenerComponent implements OnInit, OnDestroy { + private subscriptions = new Subscription(); + + ngOnInit(): void { + this.subscriptions.add( + CometChatPinSaveEvents.ccMessagePinned.subscribe(({ message, source }) => { + // Swap your copy of the message wholesale — do not patch fields. + this.replaceMessage(message); + console.log('came from', source); // "optimistic" | "sdk" | "revert" + }) + ); + + this.subscriptions.add( + CometChatPinSaveEvents.ccMessageUnpinned.subscribe(({ message }) => { + this.replaceMessage(message); + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + private replaceMessage(message: unknown): void {} +} +``` + + + Every payload carries the **full updated message**. Swap your copy rather than patching fields: `pinnedAt` and `savedAt` are present-only-when-set and are *cleared*, never zeroed, on unpin and unsave — so a partial patch leaves a stale timestamp behind and the marker never disappears. + + +The `source` field is an open string: `"optimistic"` for a local flip not yet acked, `"revert"` when a failure put it back, and `"sdk"` for a confirmed write or a realtime frame. An unrecognised value must never break a subscriber. + +--- + +## Acting Programmatically + +`PinSaveService` is provided in root and owns the behavior for every surface. + +```typescript expandable +import { Component, inject } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { PinSaveService } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-custom-pin-button', + standalone: true, + template: ``, +}) +export class CustomPinButtonComponent { + private readonly pinSave = inject(PinSaveService); + message!: CometChat.BaseMessage; + + async togglePin(): Promise { + const action = this.pinSave.isPinned(this.message) ? 'unpin' : 'pin'; + const updated = await this.pinSave.run(action, this.message); + // `null` means the call failed; the service has already reverted and shown a toast. + } +} +``` + +| Member | Returns | Description | +|:---|:---|:---| +| `isSupported()` | `boolean` | Whether the installed Chat SDK exposes the pin/save API at all | +| `isPinEnabled()` | `Promise` | The app-level pin flag, cached per session | +| `isSaveEnabled()` | `Promise` | The app-level save flag, cached per session | +| `isPinned(message)` | `boolean` | Presence of `pinnedAt` | +| `isSaved(message)` | `boolean` | Presence of `savedAt`, scoped to the viewer | +| `pinnedBy(message)` | `string \| null` | Who pinned it. `app_system` means an admin or global pin | +| `isSystemPin(message)` | `boolean` | Whether the pin came from the app rather than a person | +| `run(action, message)` | `Promise` | Performs `'pin' \| 'unpin' \| 'save' \| 'unsave'`. Returns the updated message, or `null` when it failed | + +`run()` flips the message optimistically, reconciles against the authoritative copy the server returns, and restores the pre-call attributes on failure — so a rejected pin never leaves a pin showing. One request per message is on the wire at a time, so a double-tap cannot race itself. + +--- + +## Localization + +| Key | English (US) | +|:---|:---| +| `message_list_option_organise` | Organise | +| `message_list_option_pin_message` | Pin message | +| `message_list_option_unpin_message` | Unpin message | +| `message_list_option_save_message` | Save message | +| `message_list_option_unsave_message` | Unsave message | +| `message_pinned_toast` | Message pinned | +| `message_unpinned_toast` | Message unpinned | +| `message_saved_toast` | Message saved | +| `message_unsaved_toast` | Message unsaved | +| `pinned_messages_title` | `{count} Pinned Messages` | +| `saved_messages_title` | `{count} Saved Messages` | +| `pinned_by_system` | Pinned by the app | +| `conversation_pin` | Pin conversation | +| `conversation_unpin` | Unpin conversation | +| `conversation_pinned_toast` | Conversation pinned | +| `conversation_unpinned_toast` | Conversation unpinned | +| `pin_message_limit_error` | `You can pin up to {limit} messages in a conversation.` | +| `save_message_limit_error` | `You can save up to {limit} messages.` | +| `conversation_pin_limit_error` | `You can pin up to {limit} conversations.` | +| `pin_message_permission_error` | You don't have permission to pin messages here. | +| `pin_save_generic_error` | Something went wrong. Please try again. | + +Override any of these through [Localization](/ui-kit/angular/customization/localization). + +--- + +## Related + +- [CometChatPinnedMessages](/ui-kit/angular/components/cometchat-pinned-messages) — the conversation's pinned list +- [CometChatSavedMessages](/ui-kit/angular/components/cometchat-saved-messages) — the user's saved list +- [CometChatMessageList](/ui-kit/angular/components/cometchat-message-list) — where the actions live +- [CometChatConversations](/ui-kit/angular/components/cometchat-conversations) — pinning a conversation +- [Events](/ui-kit/angular/events#cometchatpinsaveevents) — the `CometChatPinSaveEvents` reference +- [Global Configuration](/ui-kit/angular/customization/global-config) — the development overrides diff --git a/ui-kit/angular/guides/thread-subscription.mdx b/ui-kit/angular/guides/thread-subscription.mdx new file mode 100644 index 000000000..bb348ed3d --- /dev/null +++ b/ui-kit/angular/guides/thread-subscription.mdx @@ -0,0 +1,287 @@ +--- +title: "Thread Subscription" +sidebarTitle: "Thread Subscription" +description: "Let users follow and unfollow message threads so they are notified about new replies, using the CometChat Angular UIKit." +--- + + + +| Field | Value | +| --- | --- | +| Package | `@cometchat/chat-uikit-angular` | +| Key components | `cometchat-thread-header`, `cometchat-message-list` | +| Feature gate | `COMETCHAT_GLOBAL_CONFIG` → `enableThreadSubscription: true` (default **off**) | +| Service | `ThreadSubscriptionService` | +| Events | `CometChatThreadEvents.ccThreadSubscriptionChanged` | +| SDK requirement | A Chat SDK build exposing `CometChat.subscribeToThread` (4.1.14-beta-1 or later) | +| Related | [Threaded Messages](/ui-kit/angular/guides/threaded-messages), [All Guides](/ui-kit/angular/guides/guides-overview) | + + + +Thread subscription lets a user say "tell me when someone answers this" about one specific thread. Following a thread opts the user into notifications for its replies; unfollowing opts back out. It is per-user and per-thread — following changes nothing anyone else sees. + +Before starting, complete the [Integration Guide](/ui-kit/angular/integration). + +--- + +## Surfaces + +The UI Kit ships two entry points for the same action. Both read and write the same state, so toggling from one flips the other immediately. + +| Surface | Where | Label style | +|:---|:---|:---| +| Thread header control | Icon-only bell in the thread header's top bar | State-labelled — "Mute thread" / "Unmute thread" | +| Message action sheet option | In the message context menu, right after **Reply in thread** | Action-labelled — "Notify me about replies" / "Stop reply notifications" | + + + The UI Kit ships **no threads list**. If your app needs an inbox of followed threads, build it against `CometChat.ThreadsRequest` and keep it current by subscribing to [`ccThreadSubscriptionChanged`](#reacting-to-changes). + + +--- + +## Enabling the Feature + +The feature is **off by default**. There is no capability flag on the app settings that a client can feature-detect, so only you know whether the threads endpoints are deployed for your app. Opt in through `COMETCHAT_GLOBAL_CONFIG`: + +```typescript expandable +import { ApplicationConfig } from '@angular/core'; +import { COMETCHAT_GLOBAL_CONFIG, GlobalConfig } from '@cometchat/chat-uikit-angular'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: COMETCHAT_GLOBAL_CONFIG, + useValue: { + enableThreadSubscription: true, + } as GlobalConfig, + }, + ], +}; +``` + +With the gate off, neither surface renders and no thread request is ever made — whatever the per-component `hideThreadSubscription*` inputs say. + + + The kit's peer range still admits Chat SDK builds that predate the thread API. `ThreadSubscriptionService` performs a runtime capability check for `subscribeToThread`, `unsubscribeFromThread`, and `getThreadSubscriptionState`; if any is missing, both surfaces stay hidden rather than rendering a button that throws. + + +--- + +## Implementation Steps + +### 1. Turn the gate on + +Provide `enableThreadSubscription: true` as shown above. Nothing else is required — both surfaces appear on their own. + +### 2. Render the thread header + +The control lives in the thread header's top bar, beside the close button. + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { + CometChatThreadHeaderComponent, + IThreadSubscriptionChange, +} from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-thread-panel', + standalone: true, + imports: [CometChatThreadHeaderComponent], + template: ` + + + `, +}) +export class ThreadPanelComponent { + parentMessage!: CometChat.BaseMessage; + + /** Fires for every change to this thread, whoever caused it. */ + onSubscriptionChange(event: IThreadSubscriptionChange): void { + console.log(event.parentMessageId, event.state); + } + + closeThread(): void {} +} +``` + +To keep the feature but drop this particular surface — for apps that want the action-sheet entry point only — set `[hideThreadSubscriptionToggle]="true"`. + +### 3. Keep or hide the action-sheet option + +The option is added to `cometchat-message-list`'s context menu automatically. Hide it with `[hideThreadSubscriptionOption]="true"`: + +```html expandable + + +``` + +The option is offered on messages with **zero replies** — following a message before anyone answers is the point — and on replies as well, where it toggles the thread the user is already reading. + + + On a reply, the action resolves to the reply's **parent**, never the reply's own ID. CometChat has no nested threads, and subscribing to a reply ID would write a thread-list row pointing at a thread that cannot be opened. + + +--- + +## Reacting to Changes + +`CometChatThreadEvents.ccThreadSubscriptionChanged` is the channel that keeps the two surfaces in agreement without a refetch — and the channel your own thread list should subscribe to. + +```typescript expandable +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CometChatThreadEvents } from '@cometchat/chat-uikit-angular'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-thread-inbox', + standalone: true, + template: ``, +}) +export class ThreadInboxComponent implements OnInit, OnDestroy { + private subscription?: Subscription; + + ngOnInit(): void { + this.subscription = CometChatThreadEvents.ccThreadSubscriptionChanged.subscribe( + ({ parentMessageId, state, source }) => { + if (state === CometChat.ThreadSubscriptionState.SUBSCRIBED) { + this.addRow(parentMessageId); + } else { + // Unfollowing hard-deletes the row server-side — remove it, + // do not re-render it in an "unfollowed" style. + this.removeRow(parentMessageId); + } + console.log('change came from', source); // "local" or "sdk" + } + ); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } + + private addRow(id: number): void {} + private removeRow(id: number): void {} +} +``` + +### Payload + +| Field | Type | Description | +|:---|:---|:---| +| `parentMessageId` | `number` | The root message ID of the thread whose state changed | +| `state` | `CometChat.ThreadSubscriptionState` | `SUBSCRIBED`, `NOT_SUBSCRIBED`, or `UNKNOWN` | +| `source` | `string` | `"local"` for an optimistic flip not yet acked; `"sdk"` for a confirmed write or a server-initiated change. Treat it as an open string — an unrecognised value must never break your subscriber | + + + Unfollowing **hard-deletes** the thread-list row server-side. A list built on `ThreadsRequest` must remove the row, not re-render it. + + +--- + +## Reading State Directly + +`ThreadSubscriptionService` is provided in root and can be injected wherever you need to read or toggle state yourself — for example, in a custom thread row. + +```typescript expandable +import { Component, inject } from '@angular/core'; +import { ThreadSubscriptionService } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-custom-thread-row', + standalone: true, + template: ` + + `, +}) +export class CustomThreadRowComponent { + private readonly threads = inject(ThreadSubscriptionService); + parentMessageId!: number; + + get isFollowing(): boolean { + return this.threads.isFollowing(this.parentMessageId); + } + + toggle(): void { + this.threads.toggle(this.parentMessageId); + } +} +``` + +| Method | Returns | Description | +|:---|:---|:---| +| `isSupported()` | `boolean` | Whether the installed Chat SDK exposes the thread API at all | +| `getState(parentMessageId)` | `CometChat.ThreadSubscriptionState` | The state to render. Never throws and never blocks; an unknown thread reads `UNKNOWN` | +| `isFollowing(parentMessageId)` | `boolean` | `true` only for `SUBSCRIBED`. `UNKNOWN` is deliberately not "following" | +| `isUnavailable(parentMessageId)` | `boolean` | `true` once the server has said the thread is gone or off-limits | +| `toggle(parentMessageId)` | `CometChat.ThreadSubscriptionState` | Flips the subscription and returns the state to show immediately | + +--- + +## Behavior + +### Optimistic toggling + +The Chat SDK holds the authoritative state — an in-memory, per-login-session cache written only after the backend acks. Optimism lives in the UI Kit instead: the control flips instantly, the service holds the unacked value as an overlay, and the overlay is dropped — reverting to server truth — the moment the request settles either way. + +### Debounce and in-flight requests + +Rapid taps collapse into one request on a 400 ms debounce, so the user's *last* intent is what reaches the server. At most one request per thread is on the wire at a time; a tap that lands mid-request is re-armed rather than dropped. An even number of taps lands back on the server's own value and sends nothing at all. + +### Auto-subscribe + +Replying to a thread auto-subscribes the user server-side. That arrives through the SDK's thread listener and is re-emitted on `ccThreadSubscriptionChanged` with `source: "sdk"`, so both surfaces update without a refetch. + +### Errors + +A failure reverts the flip and shows a toast. `ERR_MESSAGE_NO_ACCESS` and `ERR_MESSAGE_ID_NOT_FOUND` mean the thread is off-limits or deleted — retrying cannot help, so the thread is marked unavailable and the control is withdrawn rather than left as a button that always fails. + +### Sessions + +A login or logout ends the session: pending timers are cleared, and a response that lands afterwards carrying a stale session is dropped rather than written. One user's subscription state can never leak into the next session. + +--- + +## Localization + +| Key | English (US) | +|:---|:---| +| `thread_mute` | Mute thread | +| `thread_unmute` | Unmute thread | +| `thread_followed_toast` | You'll be notified about new replies. | +| `thread_unfollowed_toast` | You won't be notified about new replies in this thread. | +| `thread_subscription_failed` | Couldn't update. Please try again. | +| `thread_unavailable` | You no longer have access to this thread. | +| `message_list_option_follow_thread` | Notify me about replies | +| `message_list_option_unfollow_thread` | Stop reply notifications | + +Override any of these through [Localization](/ui-kit/angular/customization/localization). + +--- + +## Accessibility + +- The header control is a `button` with `aria-pressed` reflecting the followed state +- Its tooltip and accessible name are the **same string**, so a voice-control user can say what the tooltip showed them (WCAG 2.5.3) +- Both states share one neutral icon color; the slash through the bell distinguishes them, so nothing rests on color alone +- Toggling announces the **outcome** through a live region, not the button's label — the label names the next action, which reads backwards after the state has changed + +--- + +## Related + +- [Threaded Messages](/ui-kit/angular/guides/threaded-messages) — building the thread view itself +- [CometChatThreadHeader](/ui-kit/angular/components/cometchat-thread-header) — the header and its follow control +- [CometChatMessageList](/ui-kit/angular/components/cometchat-message-list) — the action-sheet entry point +- [Events](/ui-kit/angular/events#cometchatthreadevents) — the `ccThreadSubscriptionChanged` reference +- [Global Configuration](/ui-kit/angular/customization/global-config) — where the feature gate lives From 1c838926cf9c672dcb897555d75e471a8a58d23b Mon Sep 17 00:00:00 2001 From: aanshisingh-cometchat Date: Mon, 7 Sep 2026 19:26:16 +0530 Subject: [PATCH 2/6] docs(ui-kit/angular): correct the pin/save/thread API to match the kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things in one commit: the pending working-tree expansion of these pages, and a set of corrections found by diffing every documented symbol against uikit-angular#559 and against React's docs PR #464. Corrections — the docs described an API that does not exist: - `IThreadSubscriptionChanged` was documented with `state: CometChat.ThreadSubscriptionState` (`SUBSCRIBED`/`NOT_SUBSCRIBED`/`UNKNOWN`) and a `source` string. Neither field exists, and `ThreadSubscriptionState` is nowhere in the repo. The payload is `{ parentMessageId, subscribed }`. React publishes the identical shape on `ui:thread/subscription-changed`, so this was a superseded design, never a platform difference. - `IPinSaveChanged.source` likewise does not exist; the payload is `{ message }`. - `ccConversationPinned`, `ccConversationUnpinned` and `IConversationPinChanged` do not exist. `CometChatConversationEvents` has only `ccConversationDeleted` and `ccUpdateConversation`. See the note below. - `ThreadSubscriptionService.getState()` does not exist, and `isFollowing()` / `toggle()` take the message, not a parent id, returning a boolean. The worked example passed an id to both. - The capability probe checks `subscribeToThread` and `unsubscribeFromThread` only. `getThreadSubscriptionState` was listed as a third probe and is not one. - The debounce was described backwards: docs said rapid taps collapse so the user's *last* intent reaches the server and a mid-request tap is re-armed. The service is leading-edge — the write leaves on the FIRST tap and anything within 400ms, or while in flight, is swallowed whole. A failed write clears the stamp so a deliberate retry is not swallowed. - "The service holds the unacked value as an overlay" described a mechanism that is not there. State is read off the message; toggle() publishes and each surface stamps its held copies, and a failure publishes the reverse. Additions — real API that was undocumented: - The optimistic tier, `ccMessagePinChanged` / `ccMessageSaveChanged` with `IPinChanged` / `ISaveChanged`. React documents its equivalent (`ui:message/pin-changed`, `ui:message/save-changed`) with the same payloads. - The merged observables `pinned$` / `unpinned$` / `saved$` / `unsaved$`, which pair both tiers per direction. The source says to prefer these over the raw subjects; the docs taught only the raw subjects. - The typed `on*` helpers, which take a `DestroyRef` and unsubscribe with the component — while events.mdx was telling readers to hand-manage subscriptions to avoid leaks. - `getSystemPinnedMessagesLimit()` / `getSystemPinnedConversationsLimit()`, folded into the existing system-pin paragraph rather than given a section of their own, since that paragraph already had the app-setting keys. Known gap, deliberately not papered over: React has three conversation pin events (`conversation/pinned`, `conversation/unpinned`, and optimistic `ui:conversation/pin-changed`); Angular publishes none. Conversation pinning works in the UI but emits nothing, so an integrator's own sidebar has no sync path. The false claim is removed and readers are pointed at `conversation.isPinned()` on render; the missing events are raised against #559. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rhePm8oMBbjPeRFzN7HTg --- .../components/cometchat-conversations.mdx | 11 ++- .../components/cometchat-message-composer.mdx | 64 +++++++++++++ .../components/cometchat-message-list.mdx | 47 ++++++--- .../components/cometchat-pinned-messages.mdx | 49 +++++++++- .../components/cometchat-saved-messages.mdx | 34 ++++++- .../components/cometchat-thread-header.mdx | 2 +- ui-kit/angular/core-features.mdx | 19 +++- .../angular/customization/global-config.mdx | 2 +- ui-kit/angular/events.mdx | 80 +++++++++++++++- .../angular/guides/custom-text-formatter.mdx | 95 +++++++++++++++++++ .../angular/guides/pin-and-save-messages.mdx | 41 ++++++-- ui-kit/angular/guides/thread-subscription.mdx | 42 ++++---- ui-kit/angular/guides/threaded-messages.mdx | 45 +++++++-- 13 files changed, 469 insertions(+), 62 deletions(-) diff --git a/ui-kit/angular/components/cometchat-conversations.mdx b/ui-kit/angular/components/cometchat-conversations.mdx index 3964fdb9c..9d7ea2385 100644 --- a/ui-kit/angular/components/cometchat-conversations.mdx +++ b/ui-kit/angular/components/cometchat-conversations.mdx @@ -355,7 +355,7 @@ Each row's context menu carries a **Pin conversation** / **Unpin conversation** ``` -The option renders whenever the installed Chat SDK exposes `pinConversation` / `unpinConversation`. There is no app-level feature flag for it, unlike [pinning a message](/ui-kit/angular/guides/pin-and-save-messages). +The option renders when conversation pinning is enabled for your app — the `features.ux.conversations.pinned.enabled` app setting, which the UI Kit reads once per session through `CometChat.isPinConversationEnabled()` — and the installed Chat SDK exposes `pinConversation` / `unpinConversation`. See [Pin and Save](/ui-kit/angular/guides/pin-and-save-messages). ### Ordering @@ -363,6 +363,12 @@ Pinned conversations are lifted above unpinned ones. The partition is **stable** An unpinned list is returned unchanged, so the common case pays nothing for this. +### System Pins + +An app can also pin a conversation **app-wide**, for everyone, rather than for one user — including a conversation that is still empty. A system pin sorts above every personal pin and cannot be lifted from the UI: it belongs to no member, so the server refuses to unpin it for any of them and the option is not offered. + +System pins are capped separately from personal ones, through the `features.ux.conversations.pinned.system.limit` app setting. + ### Reading the State ```typescript @@ -378,8 +384,9 @@ The presence of `pinnedAt` *is* the boolean — an unpinned conversation carries | Confirmation | Pinning runs straight away; **unpinning asks first**. A pin is a deliberate arrangement of the list, and a misplaced click should not undo it | | Marker | The row shows a pin marker, styled with `--cometchat-conversations-pin-color`, `--cometchat-conversations-pin-width`, and `--cometchat-conversations-pin-height` | | Accessibility | The marker is `aria-hidden`; the state reaches screen readers through the row's own accessible label | -| Limits | The pin cap is server-owned. Exceeding it shows "You can pin up to N conversations." with the real number read from the rejection | +| Limits | The cap comes from the `features.ux.conversations.pinned.limit` app setting, read through `CometChat.getPinnedConversationsLimit()`. Exceeding it shows "You can only pin N chats. Unpin one to pin another.", or generic copy when the app configures no cap | | New-message sound | The list scans every row rather than index 0 when deciding whether to play a sound, so a pinned chat sitting first does not silence it | +| Events | Pinning and unpinning publish nothing. This component re-orders itself directly, so no event is needed here — but a list of your own has none to subscribe to either. Read `conversation.isPinned()` when you render to stay in sync | ## Advanced Usage diff --git a/ui-kit/angular/components/cometchat-message-composer.mdx b/ui-kit/angular/components/cometchat-message-composer.mdx index 08ed8c3af..79a3471ab 100644 --- a/ui-kit/angular/components/cometchat-message-composer.mdx +++ b/ui-kit/angular/components/cometchat-message-composer.mdx @@ -368,6 +368,7 @@ Both layouts work seamlessly with rich text editing: | `attachmentIconView` | `TemplateRef` | `undefined` | Custom template for the attachment icon | | `voiceRecordingIconView` | `TemplateRef` | `undefined` | Custom template for the voice recording icon | | `emojiIconView` | `TemplateRef` | `undefined` | Custom template for the emoji icon | +| `toolbarTrailingView` | `TemplateRef` | `undefined` | Custom template rendered at the trailing end of the rich-text formatting toolbar. See [Toolbar Trailing View](#toolbar-trailing-view) | ## Events @@ -666,6 +667,69 @@ export class RichTextComposerComponent { - Unicode and emoji support - Undo/redo with history grouping (500ms delay) +### Toolbar Trailing View + +`toolbarTrailingView` renders your own controls at the trailing end of the rich-text formatting toolbar, after the built-in groups and an automatically inserted separator. It is the place for a button that drives a custom formatter from `textFormatters` — a color button, a highlight button, anything that acts on the text being typed. + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { + CometChatMessageComposerComponent, + CometChatTextFormatter, +} from '@cometchat/chat-uikit-angular'; +import { ColorFormatter } from './formatters/color-formatter'; + +@Component({ + selector: 'app-composer-with-color', + standalone: true, + imports: [CometChatMessageComposerComponent], + template: ` + + + + + + + ` +}) +export class ComposerWithColorComponent { + group!: CometChat.Group; + formatters: CometChatTextFormatter[] = [new ColorFormatter()]; +} +``` + +**Template context** + +| Field | Type | Description | +|:---|:---|:---| +| `composer` | `CometChatMessageComposerComponent` | The composer itself. A template cannot otherwise reach the editor, and a formatting button has to act on the text being typed — use `insertTextIntoRichTextEditor(text)` to write into it | +| `user` | `CometChat.User \| undefined` | The active one-on-one conversation, when there is one | +| `group` | `CometChat.Group \| undefined` | The active group, when there is one | + + + The slot renders only while the rich-text editor **and** its toolbar are on — `[enableRichText]="true"` with `[hideRichTextToolbar]="false"`. It is rendered in **both** the fixed toolbar and the selection bubble menu, so a slot filled once appears wherever the built-in formatting controls do. + + + + `(mousedown)="$event.preventDefault()"` is the detail that matters. Without it, clicking your button moves focus out of the editor and clears the selection before the click handler runs. + + +Your template is rendered as-is, with no wrapper class — style it yourself. It sits inside `.cometchat-message-composer__toolbar`, after an automatically inserted `.cometchat-message-composer__toolbar-separator`. Wrap several buttons in one element if you need more than one. + +For a complete walkthrough that builds the formatter as well as the button, see the [Custom Text Formatter guide](/ui-kit/angular/guides/custom-text-formatter). + ### Rich Text Enhancements The message composer includes advanced rich text features for improved user experience: diff --git a/ui-kit/angular/components/cometchat-message-list.mdx b/ui-kit/angular/components/cometchat-message-list.mdx index a0d7411a1..ebb142e2d 100644 --- a/ui-kit/angular/components/cometchat-message-list.mdx +++ b/ui-kit/angular/components/cometchat-message-list.mdx @@ -215,17 +215,17 @@ import { CometChatMessageListComponent } from '@cometchat/chat-uikit-angular'; standalone: true, imports: [CometChatMessageListComponent], template: ` - @if (chatUser && parentMessageId) { + @if (chatUser && parentMessage) { } ` }) export class ThreadViewComponent implements OnInit { - @Input() parentMessageId!: number; + @Input() parentMessage!: CometChat.BaseMessage; chatUser?: CometChat.User; async ngOnInit(): Promise { @@ -312,7 +312,8 @@ This section provides a complete reference of all @Input properties, @Output eve |----------|------|---------|-------------| | `user` | `CometChat.User` | `undefined` | User object for 1-on-1 conversations. Either `user` or `group` must be provided. | | `group` | `CometChat.Group` | `undefined` | Group object for group conversations. Either `user` or `group` must be provided. | -| `parentMessageId` | `number` | `undefined` | Parent message ID for displaying thread replies. When set, the component shows only replies to this message. | +| `parentMessage` | `CometChat.BaseMessage` | `undefined` | The thread's parent message. When set, the component shows only replies to it. **Prefer this over `parentMessageId`** — see [Thread Mode](#thread-mode). | +| `parentMessageId` | `number` | `undefined` | **Deprecated — pass `parentMessage` instead.** Enables thread mode by id. Still honoured when `parentMessage` is absent, so existing integrations keep working. | | `messagesRequestBuilder` | `CometChat.MessagesRequestBuilder` | `undefined` | Custom request builder for advanced message filtering and pagination configuration. | | `reactionsRequestBuilder` | `CometChat.ReactionsRequestBuilder` | `undefined` | Custom request builder for configuring how reactions are fetched. | | `textFormatters` | `CometChatTextFormatter[]` | `undefined` | Array of text formatters for processing message text content (mentions, links, custom patterns). | @@ -389,7 +390,7 @@ This section provides a complete reference of all @Input properties, @Output eve | `conversationStarterClick` | `string` | Emitted when a conversation starter is clicked. The payload is the starter text. | | `messagePrivatelyClick` | `{ message: CometChat.BaseMessage, user: CometChat.User }` | Emitted when "Message Privately" option is clicked in a group chat. | | `replyClick` | `CometChat.BaseMessage` | Emitted when the "Reply" option is clicked on a message. Handle this to show reply preview in composer. | -| `threadSubscriptionChange` | `IThreadSubscriptionChanged` | Emitted when a thread's follow state changes — from this list, from the thread header, or from a server-side auto-subscribe. Payload: `{ parentMessageId: number, state: CometChat.ThreadSubscriptionState, source: string }`. | +| `threadSubscriptionChange` | `IThreadSubscriptionChanged` | Emitted when a thread's follow state changes — from this list, from the thread header, or from a server-side auto-subscribe. Payload: `{ parentMessageId: number, subscribed: boolean }`. | ### Thread Subscription @@ -429,7 +430,7 @@ The menu shows Pin **or** Unpin, and Save **or** Unsave — never a toggling thi | Behavior | Detail | |:---|:---| -| Feature gate | The app-level flags reported by `CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()`, overridable via `enablePinMessage` / `enableSaveMessage` in global config | +| Feature gate | The `features.ux.messages.pinned.enabled` / `features.ux.messages.saved.enabled` app settings, reported by `CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()` and overridable via `enablePinMessage` / `enableSaveMessage` in global config | | Eligibility | Excludes deleted, in-flight, moderation-held, and `action` category messages. Thread replies **are** eligible | | Pin permission | In a group, a known participant is denied; everyone else is offered the option and the server is the backstop. Saving has no scope gate | | Confirmation | Pin and Save run immediately; **Unpin and Unsave ask first** | @@ -1126,8 +1127,8 @@ import { template: `
- - + +
@@ -1140,7 +1141,7 @@ export class ThreadPanelComponent implements AfterViewInit { @Input() user?: CometChat.User; @Input() group?: CometChat.Group; - @Input() parentMessageId?: number; + @Input() parentMessage?: CometChat.BaseMessage; // This injects the LOCAL instance, not the root singleton private bubbleConfig = inject(MessageBubbleConfigService); @@ -1180,7 +1181,7 @@ Usage with two independent panels: } @@ -2196,9 +2197,25 @@ This section covers advanced usage scenarios including thread views, reactions, Thread view allows users to have focused conversations around a specific message. When a user clicks on a message's thread indicator, you can display the thread replies in a separate view. +#### Thread Mode + +Display thread replies by passing the thread's parent message: + +```html + +``` + + + `parentMessageId` is **deprecated**. Pass the whole `parentMessage` instead. + + The id alone cannot answer "is this thread subscribed right now", and a reply arriving over the socket carries no subscription flag of its own — it has to inherit the parent's. A list holding only the id therefore renders realtime replies with the wrong subscription state in their action menu. The id used for scoping is derived from `parentMessage` when it is present, so passing both is redundant; passing only the id still works, it just opts out of realtime subscription state on replies. + + +Re-pass `parentMessage` when the parent updates in real time, so the list keeps reading its current state. + #### Basic Thread View Implementation -Display thread replies by setting the `parentMessageId` input: +Display thread replies by passing the parent message: ```typescript expandable import { Component, Input, OnInit } from '@angular/core'; @@ -2218,10 +2235,10 @@ import { CometChatMessageListComponent } from '@cometchat/chat-uikit-angular'; - @if (user && parentMessageId) { + @if (user && parentMessage) { @@ -2257,7 +2274,7 @@ import { CometChatMessageListComponent } from '@cometchat/chat-uikit-angular'; `] }) export class ThreadViewComponent implements OnInit { - @Input() parentMessageId!: number; + @Input() parentMessage!: CometChat.BaseMessage; @Input() user?: CometChat.User; @Input() group?: CometChat.Group; @@ -2346,7 +2363,7 @@ import { diff --git a/ui-kit/angular/components/cometchat-pinned-messages.mdx b/ui-kit/angular/components/cometchat-pinned-messages.mdx index 78706f8f0..40458ebe1 100644 --- a/ui-kit/angular/components/cometchat-pinned-messages.mdx +++ b/ui-kit/angular/components/cometchat-pinned-messages.mdx @@ -13,14 +13,14 @@ The Pinned Messages panel provides: - **Real message bubbles**: Each row renders the actual message bubble, so media, polls, and formatted text look as they do in the chat - **Pinned-by attribution**: Each row is labelled with who pinned it and when - **Inline unpin**: A per-row unpin control, behind a confirmation dialog -- **Row options**: Save, Copy, Info, Translate, and Report under a three-dot menu +- **Row options**: Save, Copy, Info, Translate, Report, and Message privately under a three-dot menu - **Message information**: Opens over the panel, without leaving it — Info is self-contained, so it is not forwarded to the host - **Permission-aware**: In groups, only the owner, admins, and moderators see Unpin - **Live updates**: Edits, deletions, reactions, and pin changes are reflected without a refetch - **Focus trap**: Traps keyboard focus within the panel for modal-like behavior - Pin Message is gated by an app-level feature flag that CometChat provisions server-side. Until that flag is on, `CometChat.isPinMessageEnabled()` resolves `false` and the pin surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. + Pin Message is gated by the `features.ux.messages.pinned.enabled` app setting, which CometChat provisions server-side. Until it is on, `CometChat.isPinMessageEnabled()` resolves `false` and the pin surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. ## Basic Usage @@ -139,6 +139,39 @@ export class PinnedOptionsDemoComponent { } ``` +## Filtering + +Pass a `messagesRequestBuilder` to control which pinned messages are fetched — the page size, most commonly. Call `setPinned(true)` on the builder: it is what scopes the request to pinned messages, and without it the request is an ordinary history read. + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { CometChatPinnedMessagesComponent } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-pinned-filtered', + standalone: true, + imports: [CometChatPinnedMessagesComponent], + template: ` + + + ` +}) +export class PinnedFilteredComponent { + group!: CometChat.Group; + + builder = new CometChat.MessagesRequestBuilder() + .setPinned(true) // required — scopes the fetch to pinned messages + .setLimit(30); +} +``` + + + The component re-asserts `setPinned(true)` and the `user` / `group` conversation scope on whatever builder you pass, so those are safe even if you omit them — but keep `setPinned(true)` in your code to make the intent explicit. Do not set a different conversation scope on the builder. + + ## Properties | Property | Type | Default | Description | @@ -151,7 +184,9 @@ export class PinnedOptionsDemoComponent { | `errorView` | `TemplateRef` | `undefined` | Replaces the built-in error state | | `loadingView` | `TemplateRef` | `undefined` | Replaces the built-in loading shimmer | | `messagesRequestBuilder` | `CometChat.MessagesRequestBuilder` | `undefined` | Custom request builder. Used as supplied apart from `setPinned` and the conversation scope, which are re-asserted | -| `quickOptionsCount` | `number` | `2` | How many options sit outside the overflow menu as bare icons | +| `quickOptionsCount` | `number` | `1` | How many options sit outside the overflow menu as bare icons | +| `itemView` | `TemplateRef<{ $implicit: CometChat.BaseMessage; message: CometChat.BaseMessage }>` | `undefined` | Replaces a whole pinned row. The message arrives as `$implicit` and again as `message`. A replaced row owns its own interaction | +| `textFormatters` | `CometChatTextFormatter[]` | `undefined` | Formatters applied to each row's text — mentions, links, markdown, or a custom one. Falls back to the global config's set when unset. See [Text Formatters](/ui-kit/angular/guides/custom-text-formatter) | | `hideUnpinMessageOption` | `boolean` | `false` | Hides Unpin. Role gating still wins — see [Permissions](#permissions) | | `hideSaveMessageOption` | `boolean` | `false` | Hides Save | | `hideUnsaveMessageOption` | `boolean` | `false` | Hides Unsave | @@ -159,6 +194,7 @@ export class PinnedOptionsDemoComponent { | `hideTranslateMessageOption` | `boolean` | `false` | Hides Translate | | `hideCopyMessageOption` | `boolean` | `false` | Hides Copy | | `hideFlagMessageOption` | `boolean` | `false` | Hides Report | +| `hideMessagePrivatelyOption` | `boolean` | `false` | Hides "Message privately" | ## Events @@ -183,6 +219,7 @@ Options appear in a fixed order, and `quickOptionsCount` decides how many stay o | Translate | The message is a text message | | Copy | The message is a text message | | Report | The message was sent by someone else | +| Message privately | A group conversation, and the message was sent by someone else — there is no private channel to open with yourself, and a one-on-one already is one | The list is read-only in every other respect: opening it marks nothing as read, moves no unread count, and nothing here edits or deletes a message. @@ -196,6 +233,12 @@ Unpin is deliberately **not** restricted to whoever pinned the message — anyon Role gating always wins over the input. Passing `[hideUnpinMessageOption]="false"` cannot grant a participant an unpin the server would reject anyway. A participant sees the panel with no Unpin option, rather than no panel. +### System Pins + +An app can pin a message itself, app-wide, rather than on behalf of a member. A **system pin** (`pinnedBy === "app_system"`) belongs to no one, and the server refuses to lift it for any member — so Unpin is not offered on those rows at all, here or in the message list. Save is untouched: it is private to the viewer and has nothing to do with who pinned. + +System pins are capped separately from member pins, through the `features.ux.messages.pinned.system.limit` app setting. + ### Confirmation Unpinning asks for confirmation; pinning does not. Pinning is trivially reversible and a dialog for it would only be friction, whereas unpinning removes something the whole conversation can see. diff --git a/ui-kit/angular/components/cometchat-saved-messages.mdx b/ui-kit/angular/components/cometchat-saved-messages.mdx index bb338f54a..508c944e4 100644 --- a/ui-kit/angular/components/cometchat-saved-messages.mdx +++ b/ui-kit/angular/components/cometchat-saved-messages.mdx @@ -18,7 +18,7 @@ The Saved Messages panel provides: - **Focus trap**: Traps keyboard focus within the panel for modal-like behavior - Save Message is gated by an app-level feature flag that CometChat provisions server-side. Until that flag is on, `CometChat.isSaveMessageEnabled()` resolves `false` and the save surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. + Save Message is gated by the `features.ux.messages.saved.enabled` app setting, which CometChat provisions server-side. Until it is on, `CometChat.isSaveMessageEnabled()` resolves `false` and the save surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. ## Basic Usage @@ -94,6 +94,36 @@ export class SavedReadonlyDemoComponent { export class SavedEmptyDemoComponent {} ``` +## Filtering + +Pass a `messagesRequestBuilder` to customize the fetch — the page size, most commonly. Call `setSaved(true)` on the builder: it is what scopes the request to the logged-in user's saved messages, and without it the request is an ordinary history read. + +```typescript expandable +import { Component } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { CometChatSavedMessagesComponent } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-saved-filtered', + standalone: true, + imports: [CometChatSavedMessagesComponent], + template: ` + + + ` +}) +export class SavedFilteredComponent { + builder = new CometChat.MessagesRequestBuilder() + .setSaved(true) // required — scopes the fetch to saved messages + .setLimit(30); +} +``` + + + The component re-asserts `setSaved(true)` on whatever builder you pass, so it is safe even if you omit it — but keep it in your code to make the intent explicit. Do not scope the builder to a UID or GUID: saves span every conversation. + + ## Properties | Property | Type | Default | Description | @@ -104,6 +134,8 @@ export class SavedEmptyDemoComponent {} | `emptyView` | `TemplateRef` | `undefined` | Replaces the built-in empty state | | `errorView` | `TemplateRef` | `undefined` | Replaces the built-in error state | | `loadingView` | `TemplateRef` | `undefined` | Replaces the built-in loading shimmer | +| `itemView` | `TemplateRef<{ $implicit: CometChat.BaseMessage; message: CometChat.BaseMessage }>` | `undefined` | Replaces a whole saved row. The message arrives as `$implicit` and again as `message`. A replaced row owns its own interaction | +| `textFormatters` | `CometChatTextFormatter[]` | `undefined` | Formatters applied to each row's preview text. Falls back to the global config's set when unset. See [Text Formatters](/ui-kit/angular/guides/custom-text-formatter) | | `messagesRequestBuilder` | `CometChat.MessagesRequestBuilder` | `undefined` | Custom request builder. Used as supplied apart from `setSaved`, which is re-asserted. Do not scope it to a UID/GUID — saves span conversations | ## Events diff --git a/ui-kit/angular/components/cometchat-thread-header.mdx b/ui-kit/angular/components/cometchat-thread-header.mdx index 9cbcbd92a..80af49073 100644 --- a/ui-kit/angular/components/cometchat-thread-header.mdx +++ b/ui-kit/angular/components/cometchat-thread-header.mdx @@ -216,7 +216,7 @@ The control stays enabled in every state, including an unknown one — a disable | Event | Payload Type | Description | |-------|-------------|-------------| | `closeClick` | `void` | Emitted when the close button is clicked, Enter/Space is pressed on the close button, or Escape is pressed while the header is focused | -| `threadSubscriptionChange` | `IThreadSubscriptionChange` | Emitted on every change to this thread's subscription, whoever caused it — this user's tap, another surface, or a server-side auto-subscribe. Payload: `{ parentMessageId: number; state: CometChat.ThreadSubscriptionState }` | +| `threadSubscriptionChange` | `IThreadSubscriptionChange` | Emitted on every change to this thread's subscription, whoever caused it — this user's tap, another surface, or a server-side auto-subscribe. Payload: `{ parentMessageId: number; subscribed: boolean }` | | `backClick` | `void` | **Deprecated.** Use `closeClick` instead. Emitted alongside `closeClick` for backward compatibility | ## Customization diff --git a/ui-kit/angular/core-features.mdx b/ui-kit/angular/core-features.mdx index aab782ab4..2f2983b94 100644 --- a/ui-kit/angular/core-features.mdx +++ b/ui-kit/angular/core-features.mdx @@ -9,7 +9,7 @@ description: "Review CometChat Angular UI Kit core features for messaging, media | --- | --- | | Package | `@cometchat/chat-uikit-angular` | | Required setup | `CometChatUIKit.init(UIKitSettings)` then `CometChatUIKit.login("UID")` — must complete before rendering any component | -| Core features | Instant Messaging, Media Sharing, Read Receipts, Typing Indicator, User Presence, Reactions, Mentions, Rich Text Formatting, Quoted Reply, Search, Threaded Conversations, Moderation, Report Message, Group Chat | +| Core features | Instant Messaging, Media Sharing, Read Receipts, Typing Indicator, User Presence, Reactions, Mentions, Rich Text Formatting, Quoted Reply, Pin & Save Messages, Search, Threaded Conversations, Thread Subscription, Moderation, Report Message, Group Chat | | Key components | `CometChatConversations` → [Conversations](/ui-kit/angular/components/cometchat-conversations), `CometChatMessageList` → [Message List](/ui-kit/angular/components/cometchat-message-list), `CometChatMessageComposer` → [Message Composer](/ui-kit/angular/components/cometchat-message-composer), `CometChatMessageHeader` → [Message Header](/ui-kit/angular/components/cometchat-message-header), `CometChatUsers` → [Users](/ui-kit/angular/components/cometchat-users), `CometChatGroups` → [Groups](/ui-kit/angular/components/cometchat-groups), `CometChatGroupMembers` → [Group Members](/ui-kit/angular/components/cometchat-group-members) | | CSS class prefix | `.cometchat-` | | Theming | Override CSS variables on `.cometchat` class. See [Theming](/ui-kit/angular/customization/theming) | @@ -144,7 +144,7 @@ See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription). ## Pinned and Saved Messages -Pinning highlights a message for **everyone** in a conversation; saving bookmarks one privately for the acting user, across every conversation. Both are gated by app-level feature flags provisioned by CometChat. +Pinning highlights a message for **everyone** in a conversation; saving bookmarks one privately for the acting user, across every conversation. A whole conversation can be pinned too, to the top of that user's list. Each is gated by an app setting provisioned by CometChat. | Component | Functionality | | --- | --- | @@ -155,6 +155,21 @@ Pinning highlights a message for **everyone** in a conversation; saving bookmark | [cometchat-message-header](/ui-kit/angular/components/cometchat-message-header) | Adds a "Pinned messages" entry to its overflow menu via `showPinnedMessagesOption`. | | [cometchat-conversations](/ui-kit/angular/components/cometchat-conversations) | Pins a conversation to the top of the user's list from the row context menu. | +### App Settings + +Each of the three features is switched on per app, and each carries a cap on how many items a user may pin or save. The UI Kit reads both through the Chat SDK, so no wiring is needed to show, hide, or enforce them: + +| App setting | Controls | +| --- | --- | +| `features.ux.messages.pinned.enabled` | Whether the pin surfaces render at all | +| `features.ux.messages.pinned.limit` | Pinned messages per conversation | +| `features.ux.messages.saved.enabled` | Whether the save surfaces render at all | +| `features.ux.messages.saved.limit` | Saved messages per user | +| `features.ux.conversations.pinned.enabled` | Whether the conversation pin option renders | +| `features.ux.conversations.pinned.limit` | Pinned conversations per user | + +When a user reaches a cap, the UI Kit shows a toast naming the configured number — you do not need to handle the rejection yourself. + See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages). ## Quoted Replies diff --git a/ui-kit/angular/customization/global-config.mdx b/ui-kit/angular/customization/global-config.mdx index d99b4998c..79519d24d 100644 --- a/ui-kit/angular/customization/global-config.mdx +++ b/ui-kit/angular/customization/global-config.mdx @@ -123,7 +123,7 @@ See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription). ### enablePinMessage and enableSaveMessage -These **override** the app-level feature flags the Chat SDK reports through `CometChat.isPinMessageEnabled()` and `CometChat.isSaveMessageEnabled()`. An explicit value wins; leaving a property unset falls back to the SDK's answer. +These **override** the `features.ux.messages.pinned.enabled` and `features.ux.messages.saved.enabled` app settings, which the Chat SDK reports through `CometChat.isPinMessageEnabled()` and `CometChat.isSaveMessageEnabled()`. An explicit value wins; leaving a property unset falls back to the SDK's answer. ```typescript expandable { diff --git a/ui-kit/angular/events.mdx b/ui-kit/angular/events.mdx index 90b0b2dde..4c759b8ff 100644 --- a/ui-kit/angular/events.mdx +++ b/ui-kit/angular/events.mdx @@ -84,10 +84,28 @@ Events provide decoupled communication between UIKit components using a publish/ | Field | Type | Description | | ----- | ---- | ----------- | | `parentMessageId` | `number` | The root message ID of the thread whose state changed. | -| `state` | `CometChat.ThreadSubscriptionState` | `SUBSCRIBED`, `NOT_SUBSCRIBED`, or `UNKNOWN`. | -| `source` | `string` | `"local"` for an optimistic flip not yet acked; `"sdk"` for a confirmed write or a server-initiated change. Treat it as an open string — an unrecognised value must never break a subscriber. | +| `subscribed` | `boolean` | Whether the logged-in user now follows the thread. | -This is the channel that keeps the thread header control and the message action sheet in agreement without a refetch. It is also the channel to subscribe to if you build your own threads list against `CometChat.ThreadsRequest`. +Every emission originates in the UI Kit — a manual toggle, its revert on failure, or a mirror of an auto-subscribe the server performed. The Chat SDK emits no subscription events of its own, so this subject is the only channel. + +On a matching `parentMessageId`, re-render **and** stamp `subscribed` onto the message objects you hold, so your copy stays in step with the kit's. + +This is the channel that keeps the thread header control and the message action sheet in agreement without a refetch. It is also the channel to subscribe to if you build your own threads list against `CometChat.ThreadsRequestBuilder`. + +Prefer the typed helper over subscribing to the subject directly — pass a `DestroyRef` and it unsubscribes with the component: + +```typescript +import { DestroyRef, inject } from '@angular/core'; +import { CometChatThreadEvents } from '@cometchat/chat-uikit-angular'; + +private destroyRef = inject(DestroyRef); + +ngOnInit() { + CometChatThreadEvents.onThreadSubscriptionChanged(({ parentMessageId, subscribed }) => { + // re-render, and stamp `subscribed` onto your held copies + }, this.destroyRef); +} +``` Unfollowing hard-deletes the thread-list row server-side. A list of your own must **remove** the row rather than re-render it in an "unfollowed" style. @@ -99,6 +117,13 @@ See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) `CometChatPinSaveEvents` emits events when a message is pinned, unpinned, saved, or unsaved. +It publishes on **two tiers**, and the split is the point: + +- **Server truth** — a confirmed write or a realtime frame. Authoritative. +- **This client's optimism** — the flip a surface applied before the server answered, and its reversal if the write failed. Not authoritative. + +### Server truth + | Event Name | Reach | Description | | ---------------------- | ------------------------ | ------------------------------------------------------------------------ | | **ccMessagePinned** | Broadcast | Triggered when a message is pinned. A pin is conversation-wide, so everyone in the conversation receives it. | @@ -111,7 +136,54 @@ See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) | Field | Type | Description | | ----- | ---- | ----------- | | `message` | `CometChat.BaseMessage` | The full updated message. | -| `source` | `string` | `"optimistic"` for a local flip not yet acked, `"revert"` when a failure put it back, `"sdk"` for a confirmed write or a realtime frame. Open string — handle unknown values gracefully. | + +### This client's optimism + +| Event Name | Payload | Description | +| ------------------------- | ------- | ----------- | +| **ccMessagePinChanged** | `IPinChanged` | This client's own pin flip before the server confirms it, and its revert if the write fails. | +| **ccMessageSaveChanged** | `ISaveChanged` | The same, for save. | + +**Payload (`IPinChanged` / `ISaveChanged`)** + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `message` | `CometChat.BaseMessage` | The message acted on. | +| `pinned` / `saved` | `boolean` | The state this client is **claiming**. One channel covers both directions: an optimistic pin publishes `true`, its revert publishes `false`. | + +### Which to subscribe to + +A surface almost always wants **both** tiers — the optimism for immediate feedback, the truth for correctness. Four merged observables pair them per direction, so you subscribe once and cannot drift out of step: + +| Observable | Emits when | +| ---------- | ---------- | +| `pinned$` | Pinned, whether claimed locally or confirmed by the server | +| `unpinned$` | Unpinned, either tier | +| `saved$` | Saved, either tier | +| `unsaved$` | Unsaved, either tier | + +Prefer these over the raw subjects. Each has a typed helper that takes an optional `DestroyRef` and unsubscribes with your component: + +```typescript +import { DestroyRef, inject } from '@angular/core'; +import { CometChatPinSaveEvents } from '@cometchat/chat-uikit-angular'; + +private destroyRef = inject(DestroyRef); + +ngOnInit() { + CometChatPinSaveEvents.onMessagePinned(({ message }) => { + // swap your copy of `message` wholesale + }, this.destroyRef); + + CometChatPinSaveEvents.onMessageUnpinned(({ message }) => { /* ... */ }, this.destroyRef); + CometChatPinSaveEvents.onMessageSaved(({ message }) => { /* ... */ }, this.destroyRef); + CometChatPinSaveEvents.onMessageUnsaved(({ message }) => { /* ... */ }, this.destroyRef); +} +``` + + + **No conversation pin events yet.** `CometChatConversations` re-orders itself on pin and unpin without publishing anything, so there is no event to subscribe to. To keep a list of your own in sync, read `conversation.isPinned()` when you render rather than waiting for an event. + Each payload carries the **full updated message**, so swap your copy wholesale rather than patching fields. `pinnedAt` and `savedAt` are present-only-when-set and are cleared — never zeroed — on unpin and unsave, so a partial patch leaves a stale timestamp behind and the indicator never disappears. diff --git a/ui-kit/angular/guides/custom-text-formatter.mdx b/ui-kit/angular/guides/custom-text-formatter.mdx index deae6001a..14f4fd3c4 100644 --- a/ui-kit/angular/guides/custom-text-formatter.mdx +++ b/ui-kit/angular/guides/custom-text-formatter.mdx @@ -278,6 +278,101 @@ override reset(): void { --- +## Giving Users a Way to Author It + +A formatter has two halves. Everything above is the **rendering** half — turning a marker in the raw message text into styled output wherever the message is displayed. The other half is **authoring**: giving users a way to produce that marker in the first place. + +The composer's [`toolbarTrailingView`](/ui-kit/angular/components/cometchat-message-composer#toolbar-trailing-view) is where that control goes. It renders at the trailing end of the rich-text formatting toolbar, after the built-in groups and an automatically inserted separator, and its template context carries the composer itself so your button can write into the editor. + +### 1. The formatter + +Say the marker is `{color=VALUE}…{/color}`. The formatter turns it into a colored ``: + +_File: src/app/formatters/color-formatter.ts_ + +```typescript expandable +import { CometChatTextFormatter } from '@cometchat/chat-uikit-angular'; + +/** 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 = 30; // after the URL (10) and mentions (20) formatters + + 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; + } +} +``` + + + `format()` must store `originalText`, set `formattedText`, and return the formatted string — the pipeline reads those fields. Keep it fast: it runs on every text message render. + + +### 2. The toolbar button + +Put the button in `toolbarTrailingView` and let it write the marker through the `composer` handle the template context provides. + +_File: src/app/chat/chat.component.html_ + +```html expandable + + + + + + +``` + + + `(mousedown)="$event.preventDefault()"` is the detail that matters. Without it, clicking the button moves focus out of the editor and clears the selection before your handler runs. + + +The toolbar — and therefore the trailing view — renders only while the rich-text editor and its toolbar are enabled, so pass `[enableRichText]="true"` with `[hideRichTextToolbar]="false"`. + +### 3. Register it on every surface + +The marker only becomes color where a surface actually runs the formatter. Register the same formatter everywhere the message can appear: + +```html expandable + + + + +``` + + +A formatter applies only where it is registered. Add `textFormatters` to the composer but not the message list and the author sees color while readers see raw `{color=…}` text. To set one list app-wide instead of per component, use `textFormatters` in [Global Configuration](/ui-kit/angular/customization/global-config) — every component falls back to it when its own input is unset. + + +### How it round-trips + +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 there. + +--- + ## Next Steps diff --git a/ui-kit/angular/guides/pin-and-save-messages.mdx b/ui-kit/angular/guides/pin-and-save-messages.mdx index f45cfb4f4..3fcbaf332 100644 --- a/ui-kit/angular/guides/pin-and-save-messages.mdx +++ b/ui-kit/angular/guides/pin-and-save-messages.mdx @@ -10,7 +10,7 @@ description: "Pin messages for everyone in a conversation, save messages private | --- | --- | | Package | `@cometchat/chat-uikit-angular` | | Key components | `cometchat-message-list`, `cometchat-pinned-messages`, `cometchat-saved-messages`, `cometchat-conversations` | -| Feature gate | App-level flags (`CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()`), overridable via `COMETCHAT_GLOBAL_CONFIG` | +| Feature gate | App settings `features.ux.messages.pinned.enabled` / `features.ux.messages.saved.enabled` / `features.ux.conversations.pinned.enabled`, read via `CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()` / `isPinConversationEnabled()` and overridable via `COMETCHAT_GLOBAL_CONFIG` | | Service | `PinSaveService` | | Events | `CometChatPinSaveEvents` — `ccMessagePinned`, `ccMessageUnpinned`, `ccMessageSaved`, `ccMessageUnsaved` | | State | `message.getPinnedAt()`, `getPinnedBy()`, `getSavedAt()`; `conversation.isPinned()` | @@ -34,12 +34,15 @@ Before starting, complete the [Integration Guide](/ui-kit/angular/integration). ## Enabling the Feature -Pin Message and Save Message are gated by **app-level feature flags** that CometChat provisions server-side. The UI Kit resolves them once per session through the Chat SDK: +Pin Message and Save Message are gated by **app settings** that CometChat provisions server-side. The UI Kit resolves them once per session through the Chat SDK: -- `CometChat.isPinMessageEnabled()` -- `CometChat.isSaveMessageEnabled()` +| App setting | Chat SDK accessor | +| --- | --- | +| `features.ux.messages.pinned.enabled` | `CometChat.isPinMessageEnabled()` | +| `features.ux.messages.saved.enabled` | `CometChat.isSaveMessageEnabled()` | +| `features.ux.conversations.pinned.enabled` | `CometChat.isPinConversationEnabled()` | -While a flag is off, the SDK reports the feature disabled and none of the pin or save surfaces render. +While a setting is off, the SDK reports the feature disabled and none of that feature's surfaces render. ### Development override @@ -63,10 +66,10 @@ export const appConfig: ApplicationConfig = { ``` - Leave both unset in production. The SDK's app-level flags are the right source of truth; an explicit `true` here shows options the backend may still reject. + Leave both unset in production. The app settings are the right source of truth; an explicit `true` here shows options the backend may still reject. -Pinning a **conversation** has no such flag. It renders whenever the installed Chat SDK exposes `pinConversation` / `unpinConversation`, and is hidden with `[hidePinConversation]="true"`. +Pinning a **conversation** is gated the same way, on `features.ux.conversations.pinned.enabled`. It additionally requires the installed Chat SDK to expose `pinConversation` / `unpinConversation`, and is hidden with `[hidePinConversation]="true"`. --- @@ -113,7 +116,23 @@ Pinning and saving run immediately. **Unpinning and unsaving ask first** — unp ### Limits -The server owns the pin and save caps. When one is hit, the rejection carries the limit and the toast names the real number ("You can pin up to 5 messages in a conversation."), so the copy never goes stale when the backend retunes the cap. +Each feature carries a cap, configured per app and read from app settings: + +| App setting | Caps | Chat SDK accessor | +| --- | --- | --- | +| `features.ux.messages.pinned.limit` | Pinned messages per conversation | `CometChat.getPinnedMessagesLimit()` | +| `features.ux.messages.saved.limit` | Saved messages per user | `CometChat.getSavedMessagesLimit()` | +| `features.ux.conversations.pinned.limit` | Pinned conversations per user | `CometChat.getPinnedConversationsLimit()` | + +Every accessor above resolves asynchronously — `await` it or use `.then()`. + +When a user reaches a cap, the UI Kit shows a toast naming the configured number ("You can only pin 5 messages. Unpin one to pin another."). You do not need to handle the rejection yourself. Where an app configures no cap, the toast falls back to generic copy rather than guessing a figure. + + + The caps are read once per session and cached, so the same number is available before a user reaches it — use `PinSaveService.getConfiguredLimit()` to disable a control ahead of the rejection. See [Acting Programmatically](#acting-programmatically). + + +An app may also **system-pin** messages and conversations app-wide, capped separately through `features.ux.messages.pinned.system.limit` and `features.ux.conversations.pinned.system.limit` — read through `CometChat.getSystemPinnedMessagesLimit()` and `CometChat.getSystemPinnedConversationsLimit()`. System pins belong to no member: they sort above user pins and cannot be lifted from the UI. --- @@ -298,13 +317,15 @@ export class CustomPinButtonComponent { | Member | Returns | Description | |:---|:---|:---| | `isSupported()` | `boolean` | Whether the installed Chat SDK exposes the pin/save API at all | -| `isPinEnabled()` | `Promise` | The app-level pin flag, cached per session | -| `isSaveEnabled()` | `Promise` | The app-level save flag, cached per session | +| `isPinEnabled()` | `Promise` | `features.ux.messages.pinned.enabled`, cached per session | +| `isSaveEnabled()` | `Promise` | `features.ux.messages.saved.enabled`, cached per session | +| `isPinConversationEnabled()` | `Promise` | `features.ux.conversations.pinned.enabled`, cached per session | | `isPinned(message)` | `boolean` | Presence of `pinnedAt` | | `isSaved(message)` | `boolean` | Presence of `savedAt`, scoped to the viewer | | `pinnedBy(message)` | `string \| null` | Who pinned it. `app_system` means an admin or global pin | | `isSystemPin(message)` | `boolean` | Whether the pin came from the app rather than a person | | `run(action, message)` | `Promise` | Performs `'pin' \| 'unpin' \| 'save' \| 'unsave'`. Returns the updated message, or `null` when it failed | +| `getConfiguredLimit(action)` | `Promise` | The app's cap for that action, or `null` when none is configured. Read once and reused — use it to disable a control before the user reaches the cap | `run()` flips the message optimistically, reconciles against the authoritative copy the server returns, and restores the pre-call attributes on failure — so a rejected pin never leaves a pin showing. One request per message is on the wire at a time, so a double-tap cannot race itself. diff --git a/ui-kit/angular/guides/thread-subscription.mdx b/ui-kit/angular/guides/thread-subscription.mdx index bb348ed3d..5aa919d9d 100644 --- a/ui-kit/angular/guides/thread-subscription.mdx +++ b/ui-kit/angular/guides/thread-subscription.mdx @@ -62,7 +62,7 @@ export const appConfig: ApplicationConfig = { With the gate off, neither surface renders and no thread request is ever made — whatever the per-component `hideThreadSubscription*` inputs say. - The kit's peer range still admits Chat SDK builds that predate the thread API. `ThreadSubscriptionService` performs a runtime capability check for `subscribeToThread`, `unsubscribeFromThread`, and `getThreadSubscriptionState`; if any is missing, both surfaces stay hidden rather than rendering a button that throws. + The kit's peer range still admits Chat SDK builds that predate the thread API. `ThreadSubscriptionService.isSupported()` checks for `CometChat.subscribeToThread` and `CometChat.unsubscribeFromThread`; if either is missing, both surfaces stay hidden rather than rendering a button that throws. The read side is not probed — follow state is read off the message itself, and what a *control* needs before it renders is the ability to change that state. --- @@ -151,15 +151,14 @@ export class ThreadInboxComponent implements OnInit, OnDestroy { ngOnInit(): void { this.subscription = CometChatThreadEvents.ccThreadSubscriptionChanged.subscribe( - ({ parentMessageId, state, source }) => { - if (state === CometChat.ThreadSubscriptionState.SUBSCRIBED) { + ({ parentMessageId, subscribed }) => { + if (subscribed) { this.addRow(parentMessageId); } else { // Unfollowing hard-deletes the row server-side — remove it, // do not re-render it in an "unfollowed" style. this.removeRow(parentMessageId); } - console.log('change came from', source); // "local" or "sdk" } ); } @@ -178,11 +177,16 @@ export class ThreadInboxComponent implements OnInit, OnDestroy { | Field | Type | Description | |:---|:---|:---| | `parentMessageId` | `number` | The root message ID of the thread whose state changed | -| `state` | `CometChat.ThreadSubscriptionState` | `SUBSCRIBED`, `NOT_SUBSCRIBED`, or `UNKNOWN` | -| `source` | `string` | `"local"` for an optimistic flip not yet acked; `"sdk"` for a confirmed write or a server-initiated change. Treat it as an open string — an unrecognised value must never break your subscriber | +| `subscribed` | `boolean` | Whether the logged-in user now follows the thread | + +Every emission originates in the UI Kit — a manual toggle, its revert on failure, or a mirror of an +auto-subscribe the server performed. The Chat SDK emits no subscription events of its own. + +`CometChatThreadEvents.onThreadSubscriptionChanged(cb, destroyRef)` is the same channel with automatic +cleanup; prefer it over subscribing to the subject by hand. - Unfollowing **hard-deletes** the thread-list row server-side. A list built on `ThreadsRequest` must remove the row, not re-render it. + Unfollowing **hard-deletes** the thread-list row server-side. A list built on `CometChat.ThreadsRequestBuilder` must remove the row, not re-render it. --- @@ -192,7 +196,8 @@ export class ThreadInboxComponent implements OnInit, OnDestroy { `ThreadSubscriptionService` is provided in root and can be injected wherever you need to read or toggle state yourself — for example, in a custom thread row. ```typescript expandable -import { Component, inject } from '@angular/core'; +import { Component, Input, inject } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; import { ThreadSubscriptionService } from '@cometchat/chat-uikit-angular'; @Component({ @@ -206,25 +211,26 @@ import { ThreadSubscriptionService } from '@cometchat/chat-uikit-angular'; }) export class CustomThreadRowComponent { private readonly threads = inject(ThreadSubscriptionService); - parentMessageId!: number; + + /** Both methods take the message, not an id — a reply resolves to its parent thread. */ + @Input() message!: CometChat.BaseMessage; get isFollowing(): boolean { - return this.threads.isFollowing(this.parentMessageId); + return this.threads.isFollowing(this.message); } toggle(): void { - this.threads.toggle(this.parentMessageId); + this.threads.toggle(this.message); } } ``` | Method | Returns | Description | |:---|:---|:---| -| `isSupported()` | `boolean` | Whether the installed Chat SDK exposes the thread API at all | -| `getState(parentMessageId)` | `CometChat.ThreadSubscriptionState` | The state to render. Never throws and never blocks; an unknown thread reads `UNKNOWN` | -| `isFollowing(parentMessageId)` | `boolean` | `true` only for `SUBSCRIBED`. `UNKNOWN` is deliberately not "following" | +| `isSupported()` | `boolean` | Whether the installed Chat SDK exposes the thread write API (`subscribeToThread` / `unsubscribeFromThread`) | +| `isFollowing(message)` | `boolean` | Whether to render the followed affordance. Reads the flag off the message itself, so a reply bubble answers for its own thread without consulting the parent | | `isUnavailable(parentMessageId)` | `boolean` | `true` once the server has said the thread is gone or off-limits | -| `toggle(parentMessageId)` | `CometChat.ThreadSubscriptionState` | Flips the subscription and returns the state to show immediately | +| `toggle(message)` | `boolean` | Flips the subscription and returns the state to leave the control in. The only path that writes to the server | --- @@ -232,11 +238,13 @@ export class CustomThreadRowComponent { ### Optimistic toggling -The Chat SDK holds the authoritative state — an in-memory, per-login-session cache written only after the backend acks. Optimism lives in the UI Kit instead: the control flips instantly, the service holds the unacked value as an overlay, and the overlay is dropped — reverting to server truth — the moment the request settles either way. +Follow state is read off the message itself. `toggle()` publishes the new value on `ccThreadSubscriptionChanged` before the request leaves, so every surface flips at once and stamps the value onto the message objects it holds. If the write fails, the service publishes the reverse — the surfaces flip back and re-stamp — and shows an error toast. A successful write confirms with a toast in both directions, because the icon alone is a subtle signal for something that governs whether the user hears about replies. ### Debounce and in-flight requests -Rapid taps collapse into one request on a 400 ms debounce, so the user's *last* intent is what reaches the server. At most one request per thread is on the wire at a time; a tap that lands mid-request is re-armed rather than dropped. An even number of taps lands back on the server's own value and sends nothing at all. +The write leaves on the **first** tap. A tap within 400 ms of it, or while its request is still on the wire, is swallowed whole — no publish, no request, nothing queued — and the control stays where the accepted toggle put it. That keeps an impatient double-tap from racing without deferring the request the user actually asked for, and it is the same guard the React UI Kit applies, so a double-tap lands on the same state on both platforms. + +A failed write clears the throttle stamp, so a deliberate retry straight after an error is not swallowed. ### Auto-subscribe diff --git a/ui-kit/angular/guides/threaded-messages.mdx b/ui-kit/angular/guides/threaded-messages.mdx index e410a7b4b..9d575bd00 100644 --- a/ui-kit/angular/guides/threaded-messages.mdx +++ b/ui-kit/angular/guides/threaded-messages.mdx @@ -29,7 +29,7 @@ Before starting, complete the [Integration Guide](/ui-kit/angular/integration). |:---|:---| | `cometchat-threaded-messages` | Main container for threaded messages | | `cometchat-thread-header` | Displays parent message and controls | -| `cometchat-message-list` | Shows messages filtered by `parentMessageId` | +| `cometchat-message-list` | Shows the thread's replies, given the `parentMessage` | | `cometchat-message-composer` | Input for composing threaded replies | --- @@ -90,7 +90,7 @@ Wire the `threadRepliesClick` output on `cometchat-message-list`. When a user cl ### 3. Threaded Messages Component -Render the thread panel with the parent message context, reply list filtered by `parentMessageId`, and a composer scoped to the thread. +Render the thread panel with the parent message context, the reply list scoped by `parentMessage`, and a composer scoped to the thread. ```html expandable @if (showThreadPanel && threadedMessage) { @@ -102,7 +102,7 @@ Render the thread panel with the parent message context, reply list filtered by + [parentMessage]="threadedMessage"> + [parentMessage]="threadedMessage"> @if (showComposer) { @@ -189,7 +189,7 @@ import { + [parentMessage]="threadedMessage"> + Thread subscription is **off by default**. Turn it on with `enableThreadSubscription` in [Global Configuration](/ui-kit/angular/customization/global-config#enablethreadsubscription) — no backend capability flag exists for the kit to feature-detect from, so whether the thread endpoints are deployed for your app is something only you know. + + +To react to a change, handle `(threadSubscriptionChange)` on either surface: + +```html expandable + + +``` + +To hide a surface without turning the feature off, use `[hideThreadSubscriptionToggle]` on the thread header, or `[hideThreadSubscriptionOption]` on the message list. + +See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) for the full feature — the service, the events, and the optimistic and revert behavior. + +--- + ## Feature Matrix | Feature | Component / Binding | Description | |:---|:---|:---| | Show thread option | `(threadRepliesClick)` on `cometchat-message-list` | Fires when user clicks thread reply icon | -| Display thread messages | `cometchat-message-list` with `[parentMessageId]` | Filters messages to thread replies | +| Display thread messages | `cometchat-message-list` with `[parentMessage]` | Filters messages to thread replies. `[parentMessageId]` is deprecated | | Compose reply | `cometchat-message-composer` with `[parentMessageId]` | Scopes new messages to the thread | | Thread header | `cometchat-thread-header` with `[parentMessage]` | Shows parent message context | | Close thread | `(closeClick)` on `cometchat-thread-header` | Closes the thread side panel | | Thread state | Component property `threadedMessage` | Tracks the active parent message | +| Subscribe to a thread | `(threadSubscriptionChange)` on `cometchat-thread-header` or `cometchat-message-list` | Notifies the user about new replies. Off by default — see [Thread Subscription](#thread-subscription) | --- @@ -246,6 +276,9 @@ export class ThreadedChatComponent implements OnDestroy { Customize the thread header component. + + Notify users about new replies in a thread. + Browse all feature and formatter guides. From 8242758ae4edde6e96a8f67be9e7d407ce1e05d7 Mon Sep 17 00:00:00 2001 From: aanshisingh-cometchat Date: Mon, 7 Sep 2026 20:34:25 +0530 Subject: [PATCH 3/6] docs(ui-kit/angular): restore the conversation-pin events, now that they exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed `ccConversationPinned` / `ccConversationUnpinned` because nothing in the kit published them — `CometChatConversationEvents` had only `ccConversationDeleted` and `ccUpdateConversation`, and the docs were describing React's design rather than Angular's code. uikit-angular 919f52e6 ("publish conversation-pin events and honour the SDK's ...") has since added them, so the claim is true again. Restored with the real shapes rather than the ones the docs originally guessed: - `ccConversationPinned` / `ccConversationUnpinned` carry `IConversationPinSaveChanged` — `{ conversation }`. The old docs named the payload `IConversationPinChanged` for these; that interface exists but is the optimistic one. - `ccConversationPinChanged` carries `IConversationPinChanged` — `{ conversation, pinned }`, where `pinned` is the state this client claims. - `conversationPinned$` / `conversationUnpinned$` merge both tiers, with `onConversationPinned` / `onConversationUnpinned` as the DestroyRef-aware helpers. Documented as the thing to prefer, matching how the message events are now documented. Neither the invented `source` field nor `CometChat.ThreadSubscriptionState` comes back — those remain absent from the code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rhePm8oMBbjPeRFzN7HTg --- .../components/cometchat-conversations.mdx | 2 +- ui-kit/angular/events.mdx | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/ui-kit/angular/components/cometchat-conversations.mdx b/ui-kit/angular/components/cometchat-conversations.mdx index 9d7ea2385..6e02b9175 100644 --- a/ui-kit/angular/components/cometchat-conversations.mdx +++ b/ui-kit/angular/components/cometchat-conversations.mdx @@ -386,7 +386,7 @@ The presence of `pinnedAt` *is* the boolean — an unpinned conversation carries | Accessibility | The marker is `aria-hidden`; the state reaches screen readers through the row's own accessible label | | Limits | The cap comes from the `features.ux.conversations.pinned.limit` app setting, read through `CometChat.getPinnedConversationsLimit()`. Exceeding it shows "You can only pin N chats. Unpin one to pin another.", or generic copy when the app configures no cap | | New-message sound | The list scans every row rather than index 0 when deciding whether to play a sound, so a pinned chat sitting first does not silence it | -| Events | Pinning and unpinning publish nothing. This component re-orders itself directly, so no event is needed here — but a list of your own has none to subscribe to either. Read `conversation.isPinned()` when you render to stay in sync | +| Events | Pinning and unpinning publish `ccConversationPinned` / `ccConversationUnpinned` on [`CometChatPinSaveEvents`](/ui-kit/angular/events#cometchatpinsaveevents), with `ccConversationPinChanged` for this client's own optimistic flip. This component re-orders itself without them — subscribe to keep a list of your own in sync | ## Advanced Usage diff --git a/ui-kit/angular/events.mdx b/ui-kit/angular/events.mdx index 4c759b8ff..57708ae19 100644 --- a/ui-kit/angular/events.mdx +++ b/ui-kit/angular/events.mdx @@ -181,8 +181,35 @@ ngOnInit() { } ``` +### Conversation pins + +Conversation pinning publishes on the same two tiers. + +| Event Name | Tier | Payload | Description | +| ---------- | ---- | ------- | ----------- | +| **ccConversationPinned** | Server truth | `IConversationPinSaveChanged` | A conversation was pinned — a confirmed write or a realtime frame from another device. | +| **ccConversationUnpinned** | Server truth | `IConversationPinSaveChanged` | A conversation was unpinned. | +| **ccConversationPinChanged** | Optimistic | `IConversationPinChanged` | This client's own flip before the server confirms it, and its revert if the write fails. | + +**Payloads** + +| Interface | Fields | +| --------- | ------ | +| `IConversationPinSaveChanged` | `conversation: CometChat.Conversation` | +| `IConversationPinChanged` | `conversation: CometChat.Conversation`, `pinned: boolean` — the state this client is claiming | + +As with messages, prefer the merged pair over the raw subjects — `conversationPinned$` and `conversationUnpinned$`, or their typed helpers: + +```typescript +CometChatPinSaveEvents.onConversationPinned(({ conversation }) => { + // re-order your own list +}, this.destroyRef); + +CometChatPinSaveEvents.onConversationUnpinned(({ conversation }) => { /* ... */ }, this.destroyRef); +``` + - **No conversation pin events yet.** `CometChatConversations` re-orders itself on pin and unpin without publishing anything, so there is no event to subscribe to. To keep a list of your own in sync, read `conversation.isPinned()` when you render rather than waiting for an event. + A conversation pin is private and multi-device: it rearranges only that user's list, so these arrive on their own devices and nowhere else. `CometChatConversations` re-orders itself without them — subscribe when you keep a list of your own. From 9a370c8b04d077fdee78d99eae26deb7425a7fc6 Mon Sep 17 00:00:00 2001 From: aanshisingh-cometchat Date: Mon, 7 Sep 2026 21:07:34 +0530 Subject: [PATCH 4/6] docs(ui-kit/angular): correct the reach of a conversation pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-checked the pin/save/thread docs against uikit-angular#559 at 4d890f5c and found one claim of my own that was wrong. The note added alongside the conversation-pin events said a conversation pin is "private and multi-device: it rearranges only that user's list, so these arrive on their own devices and nowhere else". That is true of a personal pin and false of an app-wide one. An admin can pin a conversation globally, carrying `pinnedBy === "app_system"`, and that reaches every user — so a frame can arrive in a session whose user did nothing. It also contradicted this doc set's own cometchat-conversations page, which already said `isPinned()` covers both. Both pages now name `isSystemPinned()` as the way to tell the two apart. The SDK exposes it for exactly this and its own docstring points at it, so comparing `getPinnedBy()` against the literal "app_system" was advice worth removing from the conversations page too. Precedence when both pins exist is the server's, and that is now stated rather than left to the reader. Also documented, from 919f52e6's own reasoning: the SDK does not echo a pin back to the device that made it, which is why CometChatConversations publishes on its own confirmed toggle — and why a surface should take the merged conversationPinned$ / conversationUnpinned$ rather than a single tier. Verified against the branch: all 57 event symbols the page names exist in projects/cometchat-uikit/src, including the six merged observables and both conversation subscribe helpers; every Conversation accessor named — isPinned, isSystemPinned, getPinnedBy, getPinnedAt — exists in the SDK typings (4.1.14-beta-15); and no phantom API has crept back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rhePm8oMBbjPeRFzN7HTg --- ui-kit/angular/components/cometchat-conversations.mdx | 2 +- ui-kit/angular/events.mdx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ui-kit/angular/components/cometchat-conversations.mdx b/ui-kit/angular/components/cometchat-conversations.mdx index 6e02b9175..b41abafd7 100644 --- a/ui-kit/angular/components/cometchat-conversations.mdx +++ b/ui-kit/angular/components/cometchat-conversations.mdx @@ -375,7 +375,7 @@ System pins are capped separately from personal ones, through the `features.ux.c const isPinned = conversation.isPinned?.(); ``` -The presence of `pinnedAt` *is* the boolean — an unpinned conversation carries no key at all, so this is never "pinned at 0". `isPinned()` covers both a personal pin and an app-wide one (`pinnedBy === "app_system"`). The accessor is optional-called because older Chat SDK builds do not expose it. +The presence of `pinnedAt` *is* the boolean — an unpinned conversation carries no key at all, so this is never "pinned at 0". `isPinned()` covers both a personal pin and an app-wide one; `isSystemPinned()` distinguishes them, which is preferable to comparing `getPinnedBy()` against `"app_system"` yourself. The accessor is optional-called because older Chat SDK builds do not expose it. ### Behavior diff --git a/ui-kit/angular/events.mdx b/ui-kit/angular/events.mdx index 57708ae19..fc63360fc 100644 --- a/ui-kit/angular/events.mdx +++ b/ui-kit/angular/events.mdx @@ -209,7 +209,13 @@ CometChatPinSaveEvents.onConversationUnpinned(({ conversation }) => { /* ... */ ``` - A conversation pin is private and multi-device: it rearranges only that user's list, so these arrive on their own devices and nowhere else. `CometChatConversations` re-orders itself without them — subscribe when you keep a list of your own. + Two kinds of pin arrive on these channels, and the reach differs. A **personal** pin is private to the user and syncs across their own devices. An **app-wide** pin, made by an admin, carries `pinnedBy === "app_system"` and applies to everyone — so a frame can reach a session whose user did nothing. `conversation.isPinned()` is true for either; use `conversation.isSystemPinned()` to tell them apart rather than string-comparing `getPinnedBy()`. Where a personal and an app-wide pin both exist, the server resolves precedence. + + `CometChatConversations` re-orders itself without these events — subscribe when you keep a list of your own. + + + + The SDK does not echo a change back to the device that made it, so `CometChatConversations` publishes on its own confirmed toggle. That is why a surface should subscribe to the merged `conversationPinned$` / `conversationUnpinned$` rather than the raw subjects: they carry both this client's flip and everyone else's. From 4fc41e946fb268f92ff806603d5e6269e027372e Mon Sep 17 00:00:00 2001 From: aanshisingh-cometchat Date: Mon, 7 Sep 2026 21:55:25 +0530 Subject: [PATCH 5/6] docs(ui-kit/angular): add the Storybook previews these two pages were missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every neighbouring component page carries a live preview — conversations has four, thread-header and message-list two each — while the two newest pages had none, which made them look less finished than the components around them. The stories now exist on #559 itself (4d890f5c), and the public Angular Storybook is already serving that build, so both ids resolve today: components-messages-cometchat-pinned-messages--default components-messages-cometchat-saved-messages--default Note the shape of those ids. Angular titles these 'CometChat Pinned Messages', so the slug carries `cometchat-`; React's docs link `components-messages-pinned-messages--default` without it. The two platforms name the same component differently, and that is worth reconciling — but the docs must match the stories that exist, so these follow Angular's. Height is 600px rather than the 250px thread-header uses: these are scrolling lists, and a short frame would show one row and hide the point of the panel. Verified: all 44 story ids referenced anywhere in ui-kit/angular resolve against https://storybook.cometchat.io/angular/index.json, these two included. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rhePm8oMBbjPeRFzN7HTg --- .../components/cometchat-pinned-messages.mdx | 14 ++++++++++++++ .../components/cometchat-saved-messages.mdx | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/ui-kit/angular/components/cometchat-pinned-messages.mdx b/ui-kit/angular/components/cometchat-pinned-messages.mdx index 40458ebe1..6f960ada4 100644 --- a/ui-kit/angular/components/cometchat-pinned-messages.mdx +++ b/ui-kit/angular/components/cometchat-pinned-messages.mdx @@ -23,6 +23,20 @@ The Pinned Messages panel provides: Pin Message is gated by the `features.ux.messages.pinned.enabled` app setting, which CometChat provisions server-side. Until it is on, `CometChat.isPinMessageEnabled()` resolves `false` and the pin surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. + + **Live Preview** — a group conversation with several pinned messages, newest pin first. + [Open in Storybook ↗](https://storybook.cometchat.io/angular/?path=/story/components-messages-cometchat-pinned-messages--default) + + + + ## Basic Usage ### Group Pinned Messages diff --git a/ui-kit/angular/components/cometchat-saved-messages.mdx b/ui-kit/angular/components/cometchat-saved-messages.mdx index 508c944e4..a3790c5e8 100644 --- a/ui-kit/angular/components/cometchat-saved-messages.mdx +++ b/ui-kit/angular/components/cometchat-saved-messages.mdx @@ -21,6 +21,20 @@ The Saved Messages panel provides: Save Message is gated by the `features.ux.messages.saved.enabled` app setting, which CometChat provisions server-side. Until it is on, `CometChat.isSaveMessageEnabled()` resolves `false` and the save surfaces do not render. See [Enabling Pin and Save](/ui-kit/angular/guides/pin-and-save-messages#enabling-the-feature) for the development override. + + **Live Preview** — saved messages drawn from several conversations, newest save first. + [Open in Storybook ↗](https://storybook.cometchat.io/angular/?path=/story/components-messages-cometchat-saved-messages--default) + + + + ## Basic Usage ### Simple Saved Messages Panel From 7570a22f6f7e9b0b54fba80a68a6c285ef4c3401 Mon Sep 17 00:00:00 2001 From: aanshisingh-cometchat Date: Tue, 8 Sep 2026 00:26:06 +0530 Subject: [PATCH 6/6] docs(ui-kit/angular): correct pin/save and thread subscription against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every claim below was checked against the Angular UI Kit source; each was wrong in a way an integrator would act on. Pin permissions had three mutually contradictory versions and none matched the kit. The pinned-messages page described an allow-list (owner/admin/moderator), the guide and message-list page a deny-list (participants denied), and the implementation has no client-side role gate at all — both `pin-save-utils.ts` and the option builder say so explicitly. The worst line told integrators a participant would not see Unpin; they do, and the server refuses the call. All three now say the same thing: no client gate, the server decides, and an allow-list was tried and removed because `getScope()` is frequently absent on a Group derived from a conversation. `event.state` does not exist — the field is `subscribed`. Two code examples used it, contradicting the events table twenty lines below one of them. `source` on `CometChatPinSaveEvents` does not exist. A code example destructured it and a paragraph documented its three values as an API contract; the two-tier design replaced it. Replaced with what the events actually carry, and pointed readers at the merged `pinned$` / `unpinned$` / `saved$` / `unsaved$` observables. Auto-subscribe does not arrive through an SDK thread listener. The SDK emits no subscription events — as the same page correctly states 68 lines earlier. The kit derives it from the message itself: a reply you authored, or one that mentions you. Four of the six documented thread localization keys were invented (`thread_mute`, `thread_unmute`, `thread_followed_toast`, `thread_unfollowed_toast`). Replaced with the real `thread_subscription_*` keys and their actual English. The "Mute thread" / "Unmute thread" labels went with them: both surfaces share one pair of strings, which contradicts the stated rationale for wording them differently. Also corrected: the minimum SDK version (4.1.14-beta-1 was never published, and released 4.1.14 carries none of these APIs — everything lands in 4.2.0), three limit-error strings, the claim that the conversation pin cap is read from the rejection rather than app settings, and `messageOptionClick` forwarding Message Privately as well as Translate and Report. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C2hqhfy48PEPCbAsgKHMQH --- .../components/cometchat-message-list.mdx | 2 +- .../components/cometchat-pinned-messages.mdx | 10 ++++++---- .../components/cometchat-thread-header.mdx | 4 ++-- .../angular/guides/pin-and-save-messages.mdx | 19 ++++++++++--------- ui-kit/angular/guides/thread-subscription.mdx | 16 ++++++++-------- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/ui-kit/angular/components/cometchat-message-list.mdx b/ui-kit/angular/components/cometchat-message-list.mdx index ebb142e2d..ba3da96a8 100644 --- a/ui-kit/angular/components/cometchat-message-list.mdx +++ b/ui-kit/angular/components/cometchat-message-list.mdx @@ -432,7 +432,7 @@ The menu shows Pin **or** Unpin, and Save **or** Unsave — never a toggling thi |:---|:---| | Feature gate | The `features.ux.messages.pinned.enabled` / `features.ux.messages.saved.enabled` app settings, reported by `CometChat.isPinMessageEnabled()` / `isSaveMessageEnabled()` and overridable via `enablePinMessage` / `enableSaveMessage` in global config | | Eligibility | Excludes deleted, in-flight, moderation-held, and `action` category messages. Thread replies **are** eligible | -| Pin permission | In a group, a known participant is denied; everyone else is offered the option and the server is the backstop. Saving has no scope gate | +| Pin permission | No client-side role gate. Pin is offered to every member and the server decides, refusing with `ERR_ACTION_NOT_ALLOWED`. Saving is private to the user and has no gate | | Confirmation | Pin and Save run immediately; **Unpin and Unsave ask first** | | Indicators | Pinned and saved bubbles keep their status-info footer and show a marker there, even mid-batch | diff --git a/ui-kit/angular/components/cometchat-pinned-messages.mdx b/ui-kit/angular/components/cometchat-pinned-messages.mdx index 6f960ada4..95992220a 100644 --- a/ui-kit/angular/components/cometchat-pinned-messages.mdx +++ b/ui-kit/angular/components/cometchat-pinned-messages.mdx @@ -15,7 +15,7 @@ The Pinned Messages panel provides: - **Inline unpin**: A per-row unpin control, behind a confirmation dialog - **Row options**: Save, Copy, Info, Translate, Report, and Message privately under a three-dot menu - **Message information**: Opens over the panel, without leaving it — Info is self-contained, so it is not forwarded to the host -- **Permission-aware**: In groups, only the owner, admins, and moderators see Unpin +- **Server-authorised**: Unpin is offered to every member; the server decides whether it is allowed - **Live updates**: Edits, deletions, reactions, and pin changes are reflected without a refetch - **Focus trap**: Traps keyboard focus within the panel for modal-like behavior @@ -216,7 +216,7 @@ export class PinnedFilteredComponent { |-------|-------------|-------------| | `closeClick` | `void` | Emitted when the panel close button is clicked, or Escape is pressed with no overlay open | | `messageClick` | `CometChat.BaseMessage` | Emitted when a row is tapped. Clicks landing on a control inside the row — a menu, an audio player, a link — are not forwarded | -| `messageOptionClick` | `{ option: ContextMenuItem; message: CometChat.BaseMessage }` | Emitted for options the panel cannot complete on its own: Translate and Report. Unpin, Save, Unsave, Copy, and Message Information are handled internally and are not forwarded | +| `messageOptionClick` | `{ option: ContextMenuItem; message: CometChat.BaseMessage }` | Emitted for options the panel cannot complete on its own: Translate, Report, and Message Privately. Message Privately also publishes `ccOpenChat` before forwarding, so a host that already listens for that does not need to handle it twice. Unpin, Save, Unsave, Copy and Message Information are handled internally and are not forwarded | | `error` | `CometChat.CometChatException` | Emitted when fetching or unpinning fails | ## Behavior @@ -239,12 +239,14 @@ The list is read-only in every other respect: opening it marks nothing as read, ### Permissions -In a group, Unpin is offered to the owner, admins, and moderators only. Ownership is read from the group's owner UID rather than the member scope, because the owner's scope arrives as `admin` on the wire. In a one-on-one conversation the option is offered and the server is the backstop. +Unpin is offered to every member, in groups and one-on-one conversations alike. There is no client-side role gate. A member without the permission sees the option, and the server refuses the call with `ERR_ACTION_NOT_ALLOWED` — the optimistic change reverts and a toast explains why. + +This is deliberate. A client-side allow-list was tried and removed: a group's `getScope()` is a membership property that is frequently absent on a `Group` derived from a conversation, so the allow-list also hid Unpin from members who genuinely had the permission. One authority, server-side, is the point. Unpin is deliberately **not** restricted to whoever pinned the message — anyone with the scope can remove any pin. - Role gating always wins over the input. Passing `[hideUnpinMessageOption]="false"` cannot grant a participant an unpin the server would reject anyway. A participant sees the panel with no Unpin option, rather than no panel. + `hideUnpinMessageOption` controls only whether the option renders. It does not grant or withhold permission — that is decided server-side when the call is made. A member without the permission still sees the option and receives an error toast on use. ### System Pins diff --git a/ui-kit/angular/components/cometchat-thread-header.mdx b/ui-kit/angular/components/cometchat-thread-header.mdx index 80af49073..6d04fcd60 100644 --- a/ui-kit/angular/components/cometchat-thread-header.mdx +++ b/ui-kit/angular/components/cometchat-thread-header.mdx @@ -189,7 +189,7 @@ export class ThreadHeaderSubscriptionComponent { parentMessage!: CometChat.BaseMessage; onSubscriptionChange(event: IThreadSubscriptionChange): void { - console.log(event.parentMessageId, event.state); + console.log(event.parentMessageId, event.subscribed); } onClose(): void {} @@ -197,7 +197,7 @@ export class ThreadHeaderSubscriptionComponent { ``` - The label is **state-labelled** here ("Mute thread" / "Unmute thread"), unlike the action-sheet option in `cometchat-message-list`, which is action-labelled. A top-bar icon reads as a state; a menu row reads as what tapping it does. + The icon is state-labelled — a plain bell when following, a struck-through bell when not — while the tooltip and accessible name say what tapping will do: "Subscribe to thread" or "Unsubscribe from thread". Those are the same two strings the action-sheet option uses, so the two surfaces never word the same action differently. The control stays enabled in every state, including an unknown one — a disabled control on a deep-linked thread is a dead end, and following something you already follow is idempotent server-side. See the [Thread Subscription guide](/ui-kit/angular/guides/thread-subscription) for the full feature. diff --git a/ui-kit/angular/guides/pin-and-save-messages.mdx b/ui-kit/angular/guides/pin-and-save-messages.mdx index 3fcbaf332..bdd3eee5d 100644 --- a/ui-kit/angular/guides/pin-and-save-messages.mdx +++ b/ui-kit/angular/guides/pin-and-save-messages.mdx @@ -102,12 +102,12 @@ A message can carry a pin or a save unless it is deleted, still in flight, held ### Permissions -Pinning is conversation-wide, so it is scope-gated the same way deleting someone else's message is: in a group, a known **participant** is denied and everyone else is offered the option. In a one-on-one there is no scope to check, so the client assumes it is allowed and the server is the backstop — a refusal returns `ERR_ACTION_NOT_ALLOWED`, and the optimistic flip reverts with an explanatory toast. +Pinning is conversation-wide, and the UI Kit applies **no client-side role gate**. Pin is offered to every member, in groups and one-on-one conversations alike, and the server decides: a refusal returns `ERR_ACTION_NOT_ALLOWED`, the optimistic flip reverts, and a toast explains why. Saving is private to the acting user, so it never needed a gate. Saving has no scope gate at all: it is private to the acting user and changes nothing anyone else can see. - The client denies only a *known* participant rather than allow-listing the elevated scopes. `getScope()` is a membership property and is frequently undefined on a `Group` derived from a conversation, so an allow-list would hide Pin from admins whose scope simply has not loaded yet. + A client-side allow-list was tried and removed. `getScope()` is a membership property and is frequently undefined on a `Group` derived from a conversation, so the allow-list also hid Pin from members who genuinely had the permission — and the Angular and React kits disagreed about who could see it. Leaving the decision to the server gives one authority instead of two. ### Confirmation @@ -228,7 +228,7 @@ The presence of `pinnedAt` is the boolean here too — an unpinned conversation - Pinning runs straight away; **unpinning asks for confirmation**, because a pin is a deliberate arrangement of the list and a misplaced click should not undo it - The row shows a pin marker, and the state reaches screen readers through the row's accessible label, since the marker itself is `aria-hidden` -- The pin cap is server-owned; exceeding it shows "You can pin up to N conversations." with the real number from the rejection +- The pin cap is server-owned; exceeding it shows "You can only pin N chats. Unpin one to pin another." The number is read from the app settings via `CometChat.getPinnedConversationsLimit()`, not scraped from the rejection. Where an app configures no cap, generic copy is shown rather than a guessed figure --- @@ -258,10 +258,9 @@ export class PinListenerComponent implements OnInit, OnDestroy { ngOnInit(): void { this.subscriptions.add( - CometChatPinSaveEvents.ccMessagePinned.subscribe(({ message, source }) => { + CometChatPinSaveEvents.pinned$.subscribe(({ message }) => { // Swap your copy of the message wholesale — do not patch fields. this.replaceMessage(message); - console.log('came from', source); // "optimistic" | "sdk" | "revert" }) ); @@ -284,7 +283,9 @@ export class PinListenerComponent implements OnInit, OnDestroy { Every payload carries the **full updated message**. Swap your copy rather than patching fields: `pinnedAt` and `savedAt` are present-only-when-set and are *cleared*, never zeroed, on unpin and unsave — so a partial patch leaves a stale timestamp behind and the marker never disappears. -The `source` field is an open string: `"optimistic"` for a local flip not yet acked, `"revert"` when a failure put it back, and `"sdk"` for a confirmed write or a realtime frame. An unrecognised value must never break a subscriber. +Events arrive on two tiers. `ccMessagePinned`, `ccMessageUnpinned`, `ccMessageSaved` and `ccMessageUnsaved` carry server truth — a confirmed write or a realtime frame. `ccMessagePinChanged` and `ccMessageSaveChanged` carry this client's own optimistic change, and its reversal if the write failed; each has a boolean (`pinned` / `saved`) saying which state is being claimed. + +Most subscribers want both tiers, so prefer the merged observables — `pinned$`, `unpinned$`, `saved$`, `unsaved$` — which deliver one direction each and cannot drift out of step with the other tier. Every payload carries the full `message`; swap your copy wholesale rather than patching fields. --- @@ -351,9 +352,9 @@ export class CustomPinButtonComponent { | `conversation_unpin` | Unpin conversation | | `conversation_pinned_toast` | Conversation pinned | | `conversation_unpinned_toast` | Conversation unpinned | -| `pin_message_limit_error` | `You can pin up to {limit} messages in a conversation.` | -| `save_message_limit_error` | `You can save up to {limit} messages.` | -| `conversation_pin_limit_error` | `You can pin up to {limit} conversations.` | +| `pin_message_limit_error` | `You can only pin {limit} messages. Unpin one to pin another.` | +| `save_message_limit_error` | `You can only save {limit} messages. Unsave one to save another.` | +| `conversation_pin_limit_error` | `You can only pin {limit} chats. Unpin one to pin another.` | | `pin_message_permission_error` | You don't have permission to pin messages here. | | `pin_save_generic_error` | Something went wrong. Please try again. | diff --git a/ui-kit/angular/guides/thread-subscription.mdx b/ui-kit/angular/guides/thread-subscription.mdx index 5aa919d9d..a977c84e0 100644 --- a/ui-kit/angular/guides/thread-subscription.mdx +++ b/ui-kit/angular/guides/thread-subscription.mdx @@ -13,7 +13,7 @@ description: "Let users follow and unfollow message threads so they are notified | Feature gate | `COMETCHAT_GLOBAL_CONFIG` → `enableThreadSubscription: true` (default **off**) | | Service | `ThreadSubscriptionService` | | Events | `CometChatThreadEvents.ccThreadSubscriptionChanged` | -| SDK requirement | A Chat SDK build exposing `CometChat.subscribeToThread` (4.1.14-beta-1 or later) | +| SDK requirement | A Chat SDK build exposing `CometChat.subscribeToThread` (4.2.0 or later) | | Related | [Threaded Messages](/ui-kit/angular/guides/threaded-messages), [All Guides](/ui-kit/angular/guides/guides-overview) | @@ -30,7 +30,7 @@ The UI Kit ships two entry points for the same action. Both read and write the s | Surface | Where | Label style | |:---|:---|:---| -| Thread header control | Icon-only bell in the thread header's top bar | State-labelled — "Mute thread" / "Unmute thread" | +| Thread header control | Icon-only bell in the thread header's top bar | State-labelled icon; tooltip and accessible name say "Subscribe to thread" / "Unsubscribe from thread" | | Message action sheet option | In the message context menu, right after **Reply in thread** | Action-labelled — "Notify me about replies" / "Stop reply notifications" | @@ -102,7 +102,7 @@ export class ThreadPanelComponent { /** Fires for every change to this thread, whoever caused it. */ onSubscriptionChange(event: IThreadSubscriptionChange): void { - console.log(event.parentMessageId, event.state); + console.log(event.parentMessageId, event.subscribed); } closeThread(): void {} @@ -248,7 +248,7 @@ A failed write clears the throttle stamp, so a deliberate retry straight after a ### Auto-subscribe -Replying to a thread auto-subscribes the user server-side. That arrives through the SDK's thread listener and is re-emitted on `ccThreadSubscriptionChanged` with `source: "sdk"`, so both surfaces update without a refetch. +Replying to a thread, or being @mentioned in one, auto-subscribes the user server-side. The Chat SDK emits no subscription events, so nothing arrives to relay: the UI Kit works the change out itself from the message it already has — a reply you authored, or one whose mentioned users include you — and publishes `ccThreadSubscriptionChanged` so both surfaces update without a refetch. No extra write is made; the server has already recorded it. ### Errors @@ -264,10 +264,10 @@ A login or logout ends the session: pending timers are cleared, and a response t | Key | English (US) | |:---|:---| -| `thread_mute` | Mute thread | -| `thread_unmute` | Unmute thread | -| `thread_followed_toast` | You'll be notified about new replies. | -| `thread_unfollowed_toast` | You won't be notified about new replies in this thread. | +| `thread_subscription_subscribe` | Subscribe to thread | +| `thread_subscription_unsubscribe` | Unsubscribe from thread | +| `thread_subscription_subscribed_toast` | Subscribed. You'll be notified about new replies in this thread. | +| `thread_subscription_unsubscribed_toast` | Unsubscribed. Notifications are off until you reply or are mentioned. | | `thread_subscription_failed` | Couldn't update. Please try again. | | `thread_unavailable` | You no longer have access to this thread. | | `message_list_option_follow_thread` | Notify me about replies |