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