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..b41abafd7 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,50 @@ 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 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 + +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. + +### 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 +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; `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 + +| 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 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 `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 ### 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-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-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..ba3da96a8 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). | @@ -335,6 +336,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 +390,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, subscribed: boolean }`. | + +### 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 `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 | 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 | + +See the [Pin and Save guide](/ui-kit/angular/guides/pin-and-save-messages) for the full feature. ### Methods @@ -1074,8 +1127,8 @@ import { template: `
- - + +
@@ -1088,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); @@ -1128,7 +1181,7 @@ Usage with two independent panels: } @@ -2144,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'; @@ -2166,10 +2235,10 @@ import { CometChatMessageListComponent } from '@cometchat/chat-uikit-angular'; - @if (user && parentMessageId) { + @if (user && parentMessage) { @@ -2205,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; @@ -2294,7 +2363,7 @@ import { 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..95992220a --- /dev/null +++ b/ui-kit/angular/components/cometchat-pinned-messages.mdx @@ -0,0 +1,318 @@ +--- +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, 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 +- **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 + + + 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 + +```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()); + } +} +``` + +## 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 | +|----------|------|---------|-------------| +| `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` | `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 | +| `hideMessageInfoOption` | `boolean` | `false` | Hides Message Information | +| `hideTranslateMessageOption` | `boolean` | `false` | Hides Translate | +| `hideCopyMessageOption` | `boolean` | `false` | Hides Copy | +| `hideFlagMessageOption` | `boolean` | `false` | Hides Report | +| `hideMessagePrivatelyOption` | `boolean` | `false` | Hides "Message privately" | + +## 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, 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 + +### 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 | +| 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. + +### Permissions + +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. + + + `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 + +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. + +### 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..a3790c5e8 --- /dev/null +++ b/ui-kit/angular/components/cometchat-saved-messages.mdx @@ -0,0 +1,232 @@ +--- +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 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 + +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 {} +``` + +## 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 | +|----------|------|---------|-------------| +| `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 | +| `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 + +| 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..6d04fcd60 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.subscribed); + } + + onClose(): void {} +} +``` + + + 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. + ## 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; subscribed: boolean }` | | `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..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) | @@ -131,6 +131,47 @@ 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. 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 | +| --- | --- | +| [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. | + +### 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 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..79519d24d 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 `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 +{ + 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..fc63360fc 100644 --- a/ui-kit/angular/events.mdx +++ b/ui-kit/angular/events.mdx @@ -71,6 +71,159 @@ 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. | +| `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, 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. + + +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. + +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. | +| **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. | + +### 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); +} +``` + +### 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); +``` + + + 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. + + + + 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/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/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..bdd3eee5d --- /dev/null +++ b/ui-kit/angular/guides/pin-and-save-messages.mdx @@ -0,0 +1,372 @@ +--- +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 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()` | +| 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 settings** that CometChat provisions server-side. The UI Kit resolves them once per session through the Chat SDK: + +| 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 setting is off, the SDK reports the feature disabled and none of that feature's 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 app settings are the right source of truth; an explicit `true` here shows options the backend may still reject. + + +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"`. + +--- + +## 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, 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. + + + 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 + +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 + +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. + +--- + +## 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 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 + +--- + +## 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.pinned$.subscribe(({ message }) => { + // Swap your copy of the message wholesale — do not patch fields. + this.replaceMessage(message); + }) + ); + + 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. + + +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. + +--- + +## 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` | `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. + +--- + +## 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 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. | + +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..a977c84e0 --- /dev/null +++ b/ui-kit/angular/guides/thread-subscription.mdx @@ -0,0 +1,295 @@ +--- +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.2.0 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 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" | + + + 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.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. + + +--- + +## 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.subscribed); + } + + 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, 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); + } + } + ); + } + + 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 | +| `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 `CometChat.ThreadsRequestBuilder` 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, Input, inject } from '@angular/core'; +import { CometChat } from '@cometchat/chat-sdk-javascript'; +import { ThreadSubscriptionService } from '@cometchat/chat-uikit-angular'; + +@Component({ + selector: 'app-custom-thread-row', + standalone: true, + template: ` + + `, +}) +export class CustomThreadRowComponent { + private readonly threads = inject(ThreadSubscriptionService); + + /** 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.message); + } + + toggle(): void { + this.threads.toggle(this.message); + } +} +``` + +| Method | Returns | Description | +|:---|:---|:---| +| `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(message)` | `boolean` | Flips the subscription and returns the state to leave the control in. The only path that writes to the server | + +--- + +## Behavior + +### Optimistic toggling + +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 + +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 + +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 + +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_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 | +| `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 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.