diff --git a/docs.json b/docs.json index d8e3711ec..c39e403ed 100644 --- a/docs.json +++ b/docs.json @@ -2720,6 +2720,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": [ @@ -2784,6 +2791,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..19c7fbd94 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: "Subscribe to thread" when the user does not follow the thread, "Unsubscribe from thread" when they do — the same wording the thread header's control uses. + +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`. The one client-side exception is a system pin, whose Unpin is withheld. Saving 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..2cea3538f --- /dev/null +++ b/ui-kit/angular/components/cometchat-pinned-messages.mdx @@ -0,0 +1,316 @@ +--- +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 +- **System pins respected**: A pin the app placed app-wide cannot be lifted by a member, so Unpin is withheld on those rows +- **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, Report, and Message privately** need surfaces the panel does not own — a translation cache, a report dialog, another conversation — so they are handed to the host rather than half-built here. Message privately also fires `CometChatUIEvents.ccOpenChat`, so a host already listening for that receives both. + +```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. 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 + +There is **no client-side role gate**. Unpin is offered to every member and the server is the sole authority — a member without the permission still sees the option, the call is refused with `ERR_ACTION_NOT_ALLOWED`, and the optimistic flip reverts with a toast. + +Unpin is deliberately **not** restricted to whoever pinned the message either — anyone the server allows can remove any pin. + + + `[hideUnpinMessageOption]="true"` is the only thing that withholds Unpin from the panel, apart from a [system pin](#system-pins). Set it yourself where your app already knows the viewer cannot unpin; the panel will not work that out on its own. + + +### 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` | `100%` | Panel width — the host owns the width, so the panel fills the space it is given | +| `--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-heading3-bold` | Header title font | +| `--cometchat-pinned-messages-item-padding` | `8px 8px` | Row padding | +| `--cometchat-pinned-messages-item-background-hover` | `--cometchat-extended-primary-color-100` | Row hover background | +| `--cometchat-pinned-messages-entry-name-font` | `--cometchat-font-body-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..e9faa0833 --- /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-heading3-bold` | 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-extended-primary-color-100` | 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..b95c0285d 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 control carries the same two labels as the action-sheet option in `cometchat-message-list` — "Subscribe to thread" / "Unsubscribe from thread" — so the action reads alike wherever it is invoked. Override both through the `thread_subscription_subscribe` and `thread_subscription_unsubscribe` keys. + + +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..7185dbe9e 100644 --- a/ui-kit/angular/guides/guides-overview.mdx +++ b/ui-kit/angular/guides/guides-overview.mdx @@ -12,7 +12,7 @@ description: "Index of task-oriented feature guides for the CometChat Angular UI | Purpose | Index of task-oriented feature guides for the Angular UIKit | | Sample app | [GitHub](https://github.com/cometchat/cometchat-uikit-angular/tree/v5/projects/sample-app) | | Components | [Components Overview](/ui-kit/angular/components/components-overview) | -| Guides | [Threaded Messages](/ui-kit/angular/guides/threaded-messages) · [Group Chat](/ui-kit/angular/guides/group-chat) · [New Chat](/ui-kit/angular/guides/new-chat) · [Search Messages](/ui-kit/angular/guides/threaded-messages) · [Block/Unblock](/ui-kit/angular/guides/block-unblock-user) · [Message Privately](/ui-kit/angular/guides/message-privately) · [Call Log Details](/ui-kit/angular/guides/call-log-details) · [Custom Message Types](/ui-kit/angular/guides/guides-overview) | +| Guides | [Threaded Messages](/ui-kit/angular/guides/threaded-messages) · [Group Chat](/ui-kit/angular/guides/group-chat) · [New Chat](/ui-kit/angular/guides/new-chat) · [Search Messages](/ui-kit/angular/guides/search-messages) · [Block/Unblock](/ui-kit/angular/guides/block-unblock-user) · [Message Privately](/ui-kit/angular/guides/message-privately) · [Call Log Details](/ui-kit/angular/guides/call-log-details) · [Custom Message Types](/ui-kit/angular/guides/custom-message-types) | @@ -27,19 +27,21 @@ 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. | +| [Search Messages](/ui-kit/angular/guides/search-messages) | Add full-text message search across conversations with result routing. | | [Block / Unblock User](/ui-kit/angular/guides/block-unblock-user) | Block or unblock users in one-to-one chats; hide composer and show unblock prompt. | | [Message Privately](/ui-kit/angular/guides/message-privately) | Launch a direct one-to-one chat from a user profile or group member list. | | [Call Log Details](/ui-kit/angular/guides/call-log-details) | Display detailed call insights: metadata, participants, join/leave history, recordings. | -| [Custom Message Types](/ui-kit/angular/guides/guides-overview) | Register custom message types with bubble templates, conversation subtitle overrides, and fetch inclusion. | +| [Custom Message Types](/ui-kit/angular/guides/custom-message-types) | Register custom message types with bubble templates, conversation subtitle overrides, and fetch inclusion. | | [Custom Text Formatter](/ui-kit/angular/guides/custom-text-formatter) | Extend the base formatter to implement custom inline patterns with regex and callbacks. | | [Mentions Formatter](/ui-kit/angular/guides/mentions-formatter) | Add @mentions with styled tokens, suggestion list, and click handling. | -| [URL Formatter](/ui-kit/angular/guides/shortcut-formatter) | Detect and style plain URLs as clickable links with optional tracking logic. | +| [URL Formatter](/ui-kit/angular/guides/url-formatter) | Detect and style plain URLs as clickable links with optional tracking logic. | | [Shortcut Formatter](/ui-kit/angular/guides/shortcut-formatter) | Provide shortcut-style text expansions invoking extension APIs or dialogs. | | [Hashtag Formatter](/ui-kit/angular/guides/hashtag-formatter) | Highlight #hashtags in the composer, message bubbles, conversation last message, and edit view. | -| [Rich Text Formatting](/ui-kit/angular/guides/custom-text-formatter) | Configure and customize the rich text editor in the message composer. | +| [Rich Text Formatting](/ui-kit/angular/guides/rich-text-formatting) | Configure and customize the rich text editor in the message composer. | Need another guide? Open a request via our [Support Portal](https://help.cometchat.com/hc/en-us/requests/new). 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..ecf8955ce --- /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()` | +| SDK requirement | `@cometchat/chat-sdk-javascript` **4.2.0 or later** — the first release exposing the pin and save APIs | +| 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 **server is the sole authority** on who may do it. There is no client-side role gate: Pin is offered to every member, and a member without the permission is refused with `ERR_ACTION_NOT_ALLOWED` — the optimistic flip then reverts with an explanatory toast. + +Saving has no gate at all: it is private to the acting user and changes nothing anyone else can see. + + + Do not rely on the UI to withhold Pin or Unpin from a participant — it does not. `getScope()` is a membership property and is frequently undefined on a `Group` derived from a conversation, so a client-side allow-list would hide Pin from admins whose scope simply has not loaded yet. The one action withheld on the client is Unpin on a system pin, which the server refuses for every member. + + +### 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 and read once per session through `CometChat.getPinnedConversationsLimit()`; exceeding it shows "You can only pin N chats. Unpin one to pin another." with that number + +--- + +## Reacting to Changes + +`CometChatPinSaveEvents` is the channel that keeps bubbles, both panels, and any surface of your own in agreement without a refetch. + +| Event | Reach | Description | +|:---|:---|:---| +| `ccMessagePinned` | Broadcast | A message was pinned — everyone in the conversation hears it | +| `ccMessageUnpinned` | Broadcast | A message was unpinned | +| `ccMessageSaved` | Private, multi-device | The logged-in user saved a message | +| `ccMessageUnsaved` | Private, multi-device | The logged-in user unsaved a message | + +```typescript expandable +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CometChatPinSaveEvents } from '@cometchat/chat-uikit-angular'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-pin-listener', + standalone: true, + template: ``, +}) +export class PinListenerComponent implements OnInit, OnDestroy { + private subscriptions = new Subscription(); + + ngOnInit(): void { + this.subscriptions.add( + CometChatPinSaveEvents.ccMessagePinned.subscribe(({ message }) => { + // 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. + + +The payload carries the message and nothing else — there is no `source` discriminator. That distinction is what the two tiers are for: `ccMessagePinned` and its siblings are **server truth** (a confirmed write or a realtime frame), while `ccMessagePinChanged` and `ccMessageSaveChanged` are **this client's optimism** (a flip applied before the server answered, and its reversal if the write failed). Most surfaces want both, so subscribe to the merged `pinned$` / `unpinned$` / `saved$` / `unsaved$` observables rather than to the raw subjects. + +--- + +## 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` | +| `pinned_messages_menu` | 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..192f12f54 --- /dev/null +++ b/ui-kit/angular/guides/thread-subscription.mdx @@ -0,0 +1,293 @@ +--- +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 | `@cometchat/chat-sdk-javascript` **4.2.0 or later** — the first release exposing `CometChat.subscribeToThread` | +| 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, and both carry the same pair of labels so the action reads alike wherever it is invoked. + +| Surface | Where | Label | +|:---|:---|:---| +| Thread header control | Icon-only bell in the thread header's top bar | "Subscribe to thread" / "Unsubscribe from thread" | +| Message action sheet option | In the message context menu, right after **Reply in thread** | "Subscribe to thread" / "Unsubscribe from thread" | + + + 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 event for it, so the UI Kit derives the change locally: `ThreadSubscriptionService.applyIncomingReply()` inspects each incoming reply, stamps the flag onto the message objects the surfaces hold, and publishes on `ccThreadSubscriptionChanged` — deliberately without re-issuing `subscribeToThread`. Both surfaces update without a refetch. + +### Errors + +A failure reverts the flip and shows a toast. `ERR_MESSAGE_NO_ACCESS` and `ERR_MESSAGE_ID_NOT_FOUND` mean the thread is off-limits or deleted — retrying cannot help, so the thread is marked unavailable and the control is withdrawn rather than left as a button that always fails. + +### Sessions + +A login or logout ends the session: pending timers are cleared, and a response that lands afterwards carrying a stale session is dropped rather than written. One user's subscription state can never leak into the next session. + +--- + +## Localization + +| Key | English (US) | +|:---|:---| +| `thread_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. | + +Both surfaces read the same two labels, so there is no separate key for the action-sheet option. 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.