diff --git a/docs.json b/docs.json
index cb43da08b..34e0566d3 100644
--- a/docs.json
+++ b/docs.json
@@ -2263,6 +2263,8 @@
"ui-kit/flutter/message-list",
"ui-kit/flutter/message-composer",
"ui-kit/flutter/threaded-messages-header",
+ "ui-kit/flutter/pinned-messages",
+ "ui-kit/flutter/saved-messages",
"ui-kit/flutter/incoming-call",
"ui-kit/flutter/outgoing-call",
"ui-kit/flutter/call-buttons",
@@ -4589,7 +4591,10 @@
"sdk/flutter/edit-message",
"sdk/flutter/flag-message",
"sdk/flutter/delete-message",
+ "sdk/flutter/pin-messages",
+ "sdk/flutter/save-messages",
"sdk/flutter/delete-conversation",
+ "sdk/flutter/pin-conversations",
"sdk/flutter/typing-indicators",
"sdk/flutter/transient-messages",
"sdk/flutter/delivery-read-receipts",
diff --git a/notifications/preferences.mdx b/notifications/preferences.mdx
index cecbda53b..be3d6aa99 100644
--- a/notifications/preferences.mdx
+++ b/notifications/preferences.mdx
@@ -164,7 +164,7 @@ CometChatNotifications.fetchPreferences(
MessagesOptions? messagesPreference = groupPreferences?.messages;
RepliesOptions? repliesPreference = groupPreferences?.replies;
- RepliesOptions? quotedRepliesPreference = groupPreferences?.quotedReplies;
+ QuotedRepliesOptions? quotedRepliesPreference = groupPreferences?.quotedReplies;
ReactionsOptions? reactionsPreference = groupPreferences?.reactions;
MemberActionsOptions? memberAddedPreference = groupPreferences?.memberAdded;
MemberActionsOptions? memberJoinedPreference = groupPreferences?.memberJoined;
@@ -323,7 +323,7 @@ NotificationPreferences updatedPreferences = NotificationPreferences();
GroupPreferences groupPreferences = GroupPreferences(
messages: MessagesOptions.SUBSCRIBE_TO_MENTIONS,
replies: RepliesOptions.SUBSCRIBE_TO_ALL,
- quotedReplies: RepliesOptions.SUBSCRIBE_TO_ALL,
+ quotedReplies: QuotedRepliesOptions.SUBSCRIBE_TO_ALL,
reactions: ReactionsOptions.SUBSCRIBE_TO_REACTIONS_ON_ALL_MESSAGES,
memberAdded: MemberActionsOptions.SUBSCRIBE,
memberJoined: MemberActionsOptions.SUBSCRIBE,
@@ -444,7 +444,7 @@ CometChatNotifications.fetchPreferences(
MessagesOptions? oneOnOneMessagesPreference = oneOnOnePreferences?.messages;
RepliesOptions? oneOnOneRepliesPreference = oneOnOnePreferences?.replies;
- RepliesOptions? oneOnOneQuotedRepliesPreference = oneOnOnePreferences?.quotedReplies;
+ QuotedRepliesOptions? oneOnOneQuotedRepliesPreference = oneOnOnePreferences?.quotedReplies;
ReactionsOptions? oneOnOneReactionsPreference = oneOnOnePreferences?.reactions;
},
onError: (e) {
@@ -567,7 +567,7 @@ NotificationPreferences updatedPreferences = NotificationPreferences();
OneOnOnePreferences oneOnOnePreferences = OneOnOnePreferences(
messages: MessagesOptions.SUBSCRIBE_TO_ALL,
replies: RepliesOptions.SUBSCRIBE_TO_MENTIONS,
- quotedReplies: RepliesOptions.SUBSCRIBE_TO_MENTIONS,
+ quotedReplies: QuotedRepliesOptions.SUBSCRIBE_TO_MENTIONS,
reactions: ReactionsOptions.SUBSCRIBE_TO_REACTIONS_ON_ALL_MESSAGES);
// Load the updates in the NotificationPreferences instance.
diff --git a/notifications/push-overview.mdx b/notifications/push-overview.mdx
index 6d83a545f..2c2ff934b 100644
--- a/notifications/push-overview.mdx
+++ b/notifications/push-overview.mdx
@@ -76,3 +76,9 @@ UI Kit implementation
+
+
+
+There is no separate guide for Flutter web builds yet. A Flutter app running on the web registers its FCM token with `PushPlatforms.FCM_WEB` (wire value `fcm_web`) instead of `FCM_FLUTTER_ANDROID` or the APNs providers, passing the token as the `fcmToken` argument to `CometChatNotifications.registerPushToken()`. Everything else — enabling push and adding an FCM provider in the dashboard — matches the Flutter (Android) guide.
+
+
diff --git a/sdk/flutter/pin-conversations.mdx b/sdk/flutter/pin-conversations.mdx
new file mode 100644
index 000000000..fb6218eec
--- /dev/null
+++ b/sdk/flutter/pin-conversations.mdx
@@ -0,0 +1,106 @@
+---
+title: "Pin Conversations"
+description: "Pin CometChat conversations to the top of the list in Flutter apps and keep every device in sync through real-time pin events."
+---
+
+
+
+Pinning a conversation surfaces it at the top of the logged-in user's conversation list. Conversation pins are **per-user** — pinning a conversation does not affect how the other participants see their lists. A pinned conversation carries a `pinnedAt` timestamp and the `pinnedBy` uid.
+
+A conversation can also be pinned for the user by an admin surface, in which case `pinnedBy` carries the `app_system` sentinel. System pins rank above the user's own pins and cannot be removed from the client.
+
+## Pin a Conversation
+
+In order to pin a conversation, you can use the `pinConversation()` method. This method takes the uid/guid of the conversation counterpart and the conversation type (`user`/`group`). On success it returns the full updated `Conversation` with `pinnedAt` and `pinnedBy` stamped.
+
+
+
+```dart
+String conversationWith = "cometchat-uid-1";
+String conversationType = CometChatConversationType.user;
+
+CometChat.pinConversation(conversationWith, conversationType,
+ onSuccess: (Conversation conversation) {
+ debugPrint("Conversation pinned at: ${conversation.pinnedAt}");
+ }, onError: (CometChatException e) {
+ debugPrint("Conversation pinning failed with exception: ${e.message}");
+});
+```
+
+
+
+
+The call is idempotent — pinning an already-pinned conversation succeeds and returns the current state.
+
+## Unpin a Conversation
+
+In order to unpin a conversation, you can use the `unpinConversation()` method. Only a pin placed by the logged-in user can be removed — an `app_system` pin is rejected server-side. The returned `Conversation` carries the pin fields cleared to `null`.
+
+
+
+```dart
+String conversationWith = "cometchat-uid-1";
+String conversationType = CometChatConversationType.user;
+
+CometChat.unpinConversation(conversationWith, conversationType,
+ onSuccess: (Conversation conversation) {
+ debugPrint("Conversation unpinned");
+ }, onError: (CometChatException e) {
+ debugPrint("Conversation unpinning failed with exception: ${e.message}");
+});
+```
+
+
+
+
+## Real-Time Pin Events
+
+Pin and unpin events are delivered to the logged-in user's devices through the `ConversationListener` class — the acting device receives the callback on success, and the user's other devices receive it over the socket, so lists stay in sync everywhere. Admin (`app_system`) pins applied server-side arrive through the same callbacks.
+
+To receive them, register a listener using the `addConversationListener()` method and override the `onConversationPinned()` and `onConversationUnpinned()` callbacks. Remove the listener with `removeConversationListener()` when it is no longer needed.
+
+
+
+```dart
+class Class_Name with ConversationListener {
+
+ //CometChat.addConversationListener("listenerId", this);
+
+ @override
+ void onConversationPinned(Conversation conversation) {
+ debugPrint("Conversation pinned: ${conversation.conversationId}");
+ }
+
+ @override
+ void onConversationUnpinned(Conversation conversation) {
+ debugPrint("Conversation unpinned: ${conversation.conversationId}");
+ }
+}
+```
+
+
+
+
+When applying these events to a conversation list, keep the ordering contract: system pins (`pinnedBy == "app_system"`) stay above user pins, and user pins stay above the activity-ordered rest of the list.
+
+## Fetching and Ordering
+
+Pinned conversations are returned by the regular `ConversationsRequest` described in [Retrieve Conversations](/sdk/flutter/retrieve-conversations), ordered pinned-first — system pins, then the user's pins, then the remaining conversations by latest activity. Inspect `conversation.pinnedAt` / `conversation.pinnedBy` on the fetched objects to render the pinned state.
+
+## Feature Availability and Limits
+
+Whether the Pin Conversation feature is enabled for the logged-in user is served on the user's login payload. You can check it at any time using the synchronous `isPinConversationEnabled()` method — it never throws, and returns `true` when the backend did not serve the flag.
+
+The maximum number of conversations a user can pin is available through `getPinnedConversationsLimit()`, which returns `null` when the backend did not serve a limit. When a pin call exceeds the cap, it fails with a limit-exceeded error whose `errorParams` map carries the authoritative limit as `{"limit": n}`.
+
+
+
+```dart
+if (CometChat.isPinConversationEnabled()) {
+ int? limit = CometChat.getPinnedConversationsLimit();
+ debugPrint("Conversation pinning enabled, limit: ${limit ?? "server default"}");
+}
+```
+
+
+
diff --git a/sdk/flutter/pin-messages.mdx b/sdk/flutter/pin-messages.mdx
new file mode 100644
index 000000000..e6112b4fd
--- /dev/null
+++ b/sdk/flutter/pin-messages.mdx
@@ -0,0 +1,146 @@
+---
+title: "Pin Messages"
+description: "Pin and unpin CometChat messages in Flutter apps, listen to pin events in real time, and fetch the pinned messages of a conversation."
+---
+
+
+
+Pinning highlights an important message for **everyone in the conversation**. A pinned message carries a `pinnedAt` timestamp and the `pinnedBy` uid of the member who pinned it, and every participant can fetch the conversation's pinned list.
+
+Pinning is permissioned in groups — only participants with the admin, moderator or owner scope can pin or unpin. In one-to-one conversations both participants can.
+
+## Pin a Message
+
+*In other words, as a member of a conversation, how do I pin a message for everyone?*
+
+In order to pin a message, you can use the `pinMessage()` method. This method takes the id of the message to be pinned. On success it returns the **full updated message** with `pinnedAt` and `pinnedBy` stamped.
+
+
+
+```dart
+int messageId = 103;
+
+CometChat.pinMessage(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Message pinned successfully: ${message.pinnedAt}");
+}, onError: (CometChatException e) {
+ debugPrint("Message pinning failed with exception: ${e.message}");
+});
+```
+
+
+
+
+The call is idempotent — pinning an already-pinned message succeeds and returns the current state.
+
+## Unpin a Message
+
+In order to unpin a message, you can use the `unpinMessage()` method. The returned message carries the pin fields cleared to `null`. The same permission model applies.
+
+
+
+```dart
+int messageId = 103;
+
+CometChat.unpinMessage(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Message unpinned successfully");
+}, onError: (CometChatException e) {
+ debugPrint("Message unpinning failed with exception: ${e.message}");
+});
+```
+
+
+
+
+## Real-Time Pin Events
+
+Pin and unpin actions are delivered to all participants through the `MessageListener` class. To receive them, register a listener using the `addMessageListener()` method and override the `onMessagePinned()` and `onMessageUnpinned()` callbacks. Both receive the full updated message object.
+
+
+
+```dart
+class Class_Name with MessageListener {
+
+ //CometChat.addMessageListener("listenerId", this);
+
+ @override
+ void onMessagePinned(BaseMessage message) {
+ debugPrint("Message pinned: ${message.id} by ${message.pinnedBy}");
+ }
+
+ @override
+ void onMessageUnpinned(BaseMessage message) {
+ debugPrint("Message unpinned: ${message.id}");
+ }
+}
+```
+
+
+
+
+The device that performed the action also receives these callbacks on success, so a single code path can update your UI for your own pins and for pins made by other members or your other devices.
+
+## Fetch Pinned Messages
+
+You can fetch all the pinned messages of a conversation by using the `MessagesRequest` class with the `pinned` parameter of the `MessagesRequestBuilder` set to `true`. A pinned list belongs to one conversation, so pair it with the `uid` (for a user conversation) or `guid` (for a group).
+
+
+
+```dart
+String UID = "cometchat-uid-1";
+
+MessagesRequest messageRequest = (MessagesRequestBuilder()
+ ..uid = UID
+ ..pinned = true
+ ..limit = 50).build();
+
+messageRequest.fetchPrevious(onSuccess: (List list) {
+ debugPrint("Pinned messages fetched: ${list.length}");
+}, onError: (CometChatException e) {
+ debugPrint("Pinned message fetching failed with exception: ${e.message}");
+});
+```
+
+
+
+
+## Feature Availability and Limits
+
+Whether the Pin Message feature is enabled for the logged-in user is served on the user's login payload. You can check it at any time using the synchronous `isPinMessageEnabled()` method — it never throws, and returns `true` when the backend did not serve the flag so the feature is not disabled on older backends.
+
+The maximum number of messages that can be pinned per conversation is also served on the login payload and is available through `getPinnedMessagesLimit()`. It returns `null` when the backend did not serve a limit.
+
+
+
+```dart
+if (CometChat.isPinMessageEnabled()) {
+ int? limit = CometChat.getPinnedMessagesLimit();
+ debugPrint("Pinning enabled, limit: ${limit ?? "server default"}");
+}
+```
+
+
+
+
+When a pin call exceeds the cap, it fails with the `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` error code. The exception's `errorParams` map carries the authoritative limit as `{"limit": n}`, which you can interpolate into your error copy.
+
+
+
+```dart
+CometChat.pinMessage(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Message pinned");
+}, onError: (CometChatException e) {
+ if (e.code == 'ERR_PINNED_MESSAGES_LIMIT_EXCEEDED') {
+ final limit = e.errorParams?['limit'];
+ debugPrint("You can only pin $limit messages. Unpin one to pin another.");
+ }
+});
+```
+
+
+
+
+
+
+Pins placed from an admin surface carry the `app_system` sentinel in `pinnedBy`. Save is the private, per-user counterpart of pinning — see [Save Messages](/sdk/flutter/save-messages).
+
+
diff --git a/sdk/flutter/save-messages.mdx b/sdk/flutter/save-messages.mdx
new file mode 100644
index 000000000..5666a6bf0
--- /dev/null
+++ b/sdk/flutter/save-messages.mdx
@@ -0,0 +1,119 @@
+---
+title: "Save Messages"
+description: "Save (bookmark) CometChat messages privately in Flutter apps, sync saves across devices, and fetch the logged-in user's saved messages."
+---
+
+
+
+Saving bookmarks a message **privately for the logged-in user**. Unlike [pinning](/sdk/flutter/pin-messages), a save is per-viewer: no other member is notified, nothing changes for the rest of the conversation, and any message the user can read — their own or someone else's — can be saved. A saved message carries a `savedAt` timestamp visible only to the user who saved it.
+
+## Save a Message
+
+In order to save a message, you can use the `saveMessage()` method. This method takes the id of the message to be saved. On success it returns the full updated message with `savedAt` stamped.
+
+
+
+```dart
+int messageId = 103;
+
+CometChat.saveMessage(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Message saved successfully: ${message.savedAt}");
+}, onError: (CometChatException e) {
+ debugPrint("Message saving failed with exception: ${e.message}");
+});
+```
+
+
+
+
+The call is idempotent — saving an already-saved message succeeds and returns the current state.
+
+## Unsave a Message
+
+In order to unsave a message, you can use the `unsaveMessage()` method. The returned message carries `savedAt` cleared to `null`.
+
+
+
+```dart
+int messageId = 103;
+
+CometChat.unsaveMessage(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Message unsaved successfully");
+}, onError: (CometChatException e) {
+ debugPrint("Message unsaving failed with exception: ${e.message}");
+});
+```
+
+
+
+
+## Real-Time Save Events
+
+Because saves are private, save events are delivered only to the **logged-in user's own devices** — the acting device receives the callback on success, and the user's other devices receive it over the socket for cross-device sync. Register a `MessageListener` using the `addMessageListener()` method and override the `onMessageSaved()` and `onMessageUnsaved()` callbacks.
+
+
+
+```dart
+class Class_Name with MessageListener {
+
+ //CometChat.addMessageListener("listenerId", this);
+
+ @override
+ void onMessageSaved(BaseMessage message) {
+ debugPrint("Message saved: ${message.id}");
+ }
+
+ @override
+ void onMessageUnsaved(BaseMessage message) {
+ debugPrint("Message unsaved: ${message.id}");
+ }
+}
+```
+
+
+
+
+## Fetch Saved Messages
+
+You can fetch the logged-in user's saved messages by using the `MessagesRequest` class with the `saved` parameter of the `MessagesRequestBuilder` set to `true`.
+
+
+
+```dart
+MessagesRequest messageRequest = (MessagesRequestBuilder()
+ ..saved = true
+ ..limit = 50).build();
+
+messageRequest.fetchPrevious(onSuccess: (List list) {
+ debugPrint("Saved messages fetched: ${list.length}");
+}, onError: (CometChatException e) {
+ debugPrint("Saved message fetching failed with exception: ${e.message}");
+});
+```
+
+
+
+
+## Feature Availability and Limits
+
+Whether the Save Message feature is enabled for the logged-in user is served on the user's login payload. You can check it at any time using the synchronous `isSaveMessageEnabled()` method — it never throws, and returns `true` when the backend did not serve the flag.
+
+The maximum number of messages a user can save is available through `getSavedMessagesLimit()`, which returns `null` when the backend did not serve a limit.
+
+When a save call exceeds the cap, it fails with the `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` error code. The exception's `errorParams` map carries the authoritative limit as `{"limit": n}`.
+
+
+
+```dart
+CometChat.saveMessage(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Message saved");
+}, onError: (CometChatException e) {
+ if (e.code == 'ERR_SAVED_MESSAGES_LIMIT_EXCEEDED') {
+ final limit = e.errorParams?['limit'];
+ debugPrint("You can only save $limit messages. Unsave one to save another.");
+ }
+});
+```
+
+
+
diff --git a/sdk/flutter/threaded-messages.mdx b/sdk/flutter/threaded-messages.mdx
index c796bcd1b..b6ae0f6fb 100644
--- a/sdk/flutter/threaded-messages.mdx
+++ b/sdk/flutter/threaded-messages.mdx
@@ -149,3 +149,159 @@ messageRequest.fetchNext(onSuccess: (List list) {
The above snippet will return messages between the logged in user and `cometchat-uid-1` excluding all the threaded messages belonging to the same conversation.
+
+## Thread Subscriptions
+
+Subscribing to (following) a thread lets the logged-in user be notified about new replies in that thread. The SDK exposes the server's answer on the message and two calls to change it — subscription state lives on the message, not in the SDK.
+
+### Subscribe to a Thread
+
+In order to subscribe to a thread, you can use the `subscribeToThread()` method. This method takes the id of the thread's parent message. Subscribing is allowed even on a message that has no replies yet.
+
+
+
+```dart
+int parentMessageId = 103;
+
+CometChat.subscribeToThread(parentMessageId, onSuccess: (String response) {
+ debugPrint("Subscribed to thread successfully");
+}, onError: (CometChatException e) {
+ debugPrint("Thread subscription failed with exception: ${e.message}");
+});
+```
+
+
+
+
+The call is idempotent — subscribing to an already-followed thread succeeds and is not an error. **The resolved call is the acknowledgement**: there is no follow-up event.
+
+### Unsubscribe from a Thread
+
+In order to unsubscribe from a thread, you can use the `unsubscribeFromThread()` method.
+
+
+
+```dart
+int parentMessageId = 103;
+
+CometChat.unsubscribeFromThread(parentMessageId, onSuccess: (String response) {
+ debugPrint("Unsubscribed from thread successfully");
+}, onError: (CometChatException e) {
+ debugPrint("Thread unsubscription failed with exception: ${e.message}");
+});
+```
+
+
+
+
+
+
+Unfollowing is not sticky: replying to the thread again, or being @-mentioned in it, re-subscribes the user. Do not promise otherwise in UI copy. Unsubscribing also hard-deletes the subscription server-side — if you are holding a fetched thread list, remove the row locally when the call resolves.
+
+
+
+### Read the Subscription State
+
+Subscription state lives **on the message**. Every message fetch opts in with `withThreadSubscribed=true` automatically, and the server returns the flag on the message payload — the SDK normalises it and exposes it as the `threadSubscribed` boolean on any fetched message. The flag is per-viewer: the same message yields different values for different users.
+
+
+
+```dart
+if (message.threadSubscribed) {
+ debugPrint("Following this thread");
+}
+```
+
+
+
+
+Two rules to internalise:
+
+1. The flag is only populated on responses to requests that **asked** for it. A message delivered over the socket carries no flag and therefore reads `false` — meaning "the server did not tell me", not "the user is unsubscribed".
+2. **A message's author is subscribed to its thread by default.** A fetched message the logged-in user sent reading `false` is therefore not silence — it is an explicit unsubscribe, and nothing should override it.
+
+Because subscribing is idempotent, rendering an un-told `false` as the un-followed affordance is safe — an unnecessary subscribe is harmless.
+
+### Re-read the State for One Message
+
+When you need an authoritative flag for a single message — after acting on a socket-delivered message, or to build a thread row without refetching a list — use `getMessageDetails()`. It takes a message id and carries the subscription opt-in, so the message it resolves with has `threadSubscribed` populated.
+
+
+
+```dart
+int messageId = 103;
+
+CometChat.getMessageDetails(messageId, onSuccess: (BaseMessage message) {
+ debugPrint("Thread subscribed: ${message.threadSubscribed}");
+}, onError: (CometChatException e) {
+ debugPrint("Message details fetching failed with exception: ${e.message}");
+});
+```
+
+
+
+
+### Reacting to Replies
+
+A thread reply is an ordinary message with `parentMessageId` set, delivered through the standard `MessageListener` alongside every other message — there is no thread-specific event channel. The same listener also delivers `onMessageEdited` and `onMessageDeleted` for replies, which a replies-only channel would miss.
+
+
+
+```dart
+class Class_Name with MessageListener {
+
+ //CometChat.addMessageListener("listenerId", this);
+
+ @override
+ void onTextMessageReceived(TextMessage textMessage) {
+ if (textMessage.parentMessageId != 0) {
+ debugPrint("A reply landed in thread ${textMessage.parentMessageId}");
+ }
+ }
+}
+```
+
+
+
+
+Your own replies do not arrive on a listener — update your UI from the send call's success callback.
+
+### Fetch Participated Threads
+
+You can fetch the threads the logged-in user participates in by using the `ThreadsRequest` class. The `ThreadsRequestBuilder` builds the request using `setLimit()` and `setParticipatedByMe()`; once you have the `ThreadsRequest` object, call `fetchNext()` to get the next set of threads. Presence in this list *is* a subscription — every row is subscribed.
+
+
+
+```dart
+ThreadsRequest threadsRequest = (ThreadsRequestBuilder()
+ ..setParticipatedByMe(true)
+ ..setLimit(30)).build();
+
+threadsRequest.fetchNext(onSuccess: (List threads) {
+ debugPrint("Fetched ${threads.length} threads");
+}, onError: (CometChatException e) {
+ debugPrint("Thread fetching failed with exception: ${e.message}");
+});
+```
+
+
+
+
+To scope the list to a single conversation, add `setUid()` for a 1-1 counterpart or `setGuid()` for a group. The two are mutually exclusive — setting both is a validation error raised when you call `fetchNext()`.
+
+
+
+```dart
+ThreadsRequest threadsRequest = (ThreadsRequestBuilder()
+ ..setGuid("cometchat-guid-1")
+ ..setLimit(30)).build();
+```
+
+
+
+
+
+
+A `ThreadsRequest` is single-use and one-directional: it accumulates its paging state internally and has no reset. To refresh a list, build a new request from the builder and replace the list rather than re-running an exhausted one. This is the same contract as `ConversationsRequest` and `MessagesRequest`.
+
+
diff --git a/ui-kit/flutter/conversations.mdx b/ui-kit/flutter/conversations.mdx
index bda98e431..fee7f0dbf 100644
--- a/ui-kit/flutter/conversations.mdx
+++ b/ui-kit/flutter/conversations.mdx
@@ -263,6 +263,7 @@ The component listens to these SDK events internally. No manual setup needed.
| `hideSearch` | `bool?` | `null` | Toggle search bar |
| `searchReadOnly` | `bool` | `false` | Make search bar read-only (tap opens custom search) |
| `deleteConversationOptionVisibility` | `bool?` | `true` | Show delete option on long press |
+| `pinConversationOptionVisibility` | `bool?` | `true` | Show pin/unpin option on long press |
| `groupTypeVisibility` | `bool?` | `true` | Show group type icon on avatar |
| `usersStatusVisibility` | `bool?` | `true` | Show online/offline status indicator |
| `receiptsVisibility` | `bool?` | `true` | Show message receipts |
@@ -428,6 +429,26 @@ CometChatConversations(
---
+## Pin Conversations
+
+The long-press menu offers **Pin conversation** / **Unpin conversation** alongside Delete. Pinned conversations form a shelf at the top of the list, ordered system pins first (placed by an admin surface, `pinnedBy == "app_system"`), then the user's own pins, then the remaining conversations by latest activity — and real-time events keep that ordering: new activity floats a conversation to the top of **its own section only**, never above a pin.
+
+Everything is wired internally through the SDK's [Pin Conversations](/sdk/flutter/pin-conversations) APIs: the component performs the pin/unpin call, shows a confirmation toast (or the cap error with the server's limit interpolated), and reorders the list from the SDK's `ConversationListener` events — including pins made from the user's other devices.
+
+To hide the option, set `pinConversationOptionVisibility` to `false`. The option also hides itself when the feature is disabled for the logged-in user (`CometChat.isPinConversationEnabled()`), and for rows pinned by `app_system`, which are not user-removable.
+
+
+
+```dart
+CometChatConversations(
+ pinConversationOptionVisibility: false, // hide pin/unpin from the long-press menu
+)
+```
+
+
+
+---
+
## Common Patterns
### Minimal list — hide all chrome
diff --git a/ui-kit/flutter/events.mdx b/ui-kit/flutter/events.mdx
index aafde12a6..ba3b04fe9 100644
--- a/ui-kit/flutter/events.mdx
+++ b/ui-kit/flutter/events.mdx
@@ -113,6 +113,18 @@ class _YourScreenState extends State with CometChatGroupEventListene
`CometChatMessageEvents` emits events related to messages.
+Pin, save and thread-subscription actions taken on a Kit surface are announced here, each carrying the full updated message so a custom listing can add or drop a row without a refetch:
+
+1. `ccMessagePinned` / `ccMessageUnpinned`: Triggered when the logged-in user pins or unpins a message.
+2. `ccMessageSaved` / `ccMessageUnsaved`: Triggered when the logged-in user saves or unsaves a message.
+3. `ccThreadSubscriptionChanged`: Triggered when the logged-in user follows or unfollows a thread. Carries `(int parentMessageId, bool subscribed)` rather than a message. An unfollow also hard-deletes the thread's row server-side, so remove it locally.
+
+
+
+All five are default no-ops on `CometChatMessageEventListener`, so existing listeners need no change to keep compiling.
+
+
+
```dart
diff --git a/ui-kit/flutter/guide-threaded-messages.mdx b/ui-kit/flutter/guide-threaded-messages.mdx
index d63735c6d..b5ba44934 100644
--- a/ui-kit/flutter/guide-threaded-messages.mdx
+++ b/ui-kit/flutter/guide-threaded-messages.mdx
@@ -102,6 +102,31 @@ CometChatMessageComposer(
+## Thread Subscriptions (Follow / Unfollow)
+
+Users can follow a thread to be notified about new replies, and unfollow to mute it. The UI Kit wires this end to end on top of the SDK's [Thread Subscriptions](/sdk/flutter/threaded-messages#thread-subscriptions) APIs — subscriptions also happen automatically when the user replies to a thread or is @-mentioned in one.
+
+Enable the feature through `UIKitSettings` when initializing the UI Kit:
+
+
+
+```dart
+UIKitSettings uiKitSettings = (UIKitSettingsBuilder()
+ ..appId = "APP_ID"
+ ..region = "REGION"
+ ..authKey = "AUTH_KEY"
+ ..enableThreadSubscription = true
+).build();
+```
+
+
+
+With the gate on:
+
+- The message action menu offers **Subscribe to thread** / **Unsubscribe from thread** on threaded messages, with confirmation toasts ("Subscribed. You'll be notified about new replies in this thread.").
+- The thread screen's header shows a **notification bell** reflecting the live subscription state — tapping it toggles the subscription. Pass the thread's root message as `parentMessage` to `CometChatMessageHeader` to render it; on `CometChatThreadedHeader`, the bell can be hidden with `threadSubscriptionVisibility: false` when the header above it already carries one.
+- All copy is localized through the UI Kit's translations.
+
## Customization Options
- Header Styling: Customize `CometChatThreadedHeader` appearance
diff --git a/ui-kit/flutter/message-composer.mdx b/ui-kit/flutter/message-composer.mdx
index 9862351e8..5aaf28f85 100644
--- a/ui-kit/flutter/message-composer.mdx
+++ b/ui-kit/flutter/message-composer.mdx
@@ -459,6 +459,63 @@ CometChatMessageComposer(
+### Trailing toolbar actions
+
+`richTextToolbarActions` appends your own buttons at the trailing end of the built-in toolbar — after the format buttons, separated by a divider — without replacing the toolbar the way `richTextToolbarView` does. It takes the same `ComposerActionsBuilder` shape as `attachmentOptions` and returns a list of `CometChatMessageComposerAction` items.
+
+For toolbar actions, set the action's `onToolbarTap` callback. It receives the `BuildContext` and the composer's active `TextEditingController`, so the action can read the current text and selection and mutate the draft — the composer stays the owner of the field.
+
+
+
+```dart
+CometChatMessageComposer(
+ user: user,
+ richTextToolbarActions: (context, user, group, id) => [
+ CometChatMessageComposerAction(
+ id: 'insert_greeting',
+ title: 'Greeting',
+ icon: const Icon(Icons.waving_hand_outlined),
+ onToolbarTap: (context, controller) {
+ final selection = controller.selection;
+ final offset = selection.isValid ? selection.start : controller.text.length;
+ controller.text = controller.text.replaceRange(offset, offset, 'Hello! ');
+ },
+ ),
+ ],
+)
+```
+
+
+
+
+
+On web, tapping a toolbar button can blur the text field and collapse the selection before your handler runs. When the controller is a `RichTextEditingController`, fall back to its `lastNonCollapsedSelection` — the most recent valid selection the field held — and bounds-check it against the current text.
+
+
+
+#### Leaving mentions alone
+
+A toolbar action that restyles a range will happily run over a mention, which should keep the styling the mentions formatter gives it. `RichTextEditingController.getMentionRanges()` returns the mention ranges currently tracked in the text, sorted by start offset, so an action can skip them.
+
+The ranges come from the attached `CometChatMentionsFormatter` — the same source the field styles mentions from — so they stay in step with the text as it is edited. The list is empty when no mentions formatter is attached, which is also what you get on a plain `TextEditingController`, so guard the cast.
+
+
+
+```dart
+onToolbarTap: (context, controller) {
+ final mentions = controller is RichTextEditingController
+ ? controller.getMentionRanges()
+ : const [];
+
+ bool overlapsMention(int start, int end) =>
+ mentions.any((r) => start < r.end && end > r.start);
+
+ // ...apply your styling only where overlapsMention() is false
+},
+```
+
+
+
***
## Multiple Attachments
diff --git a/ui-kit/flutter/message-header.mdx b/ui-kit/flutter/message-header.mdx
index f723cfbb7..3c8ed43a7 100644
--- a/ui-kit/flutter/message-header.mdx
+++ b/ui-kit/flutter/message-header.mdx
@@ -145,6 +145,35 @@ The component listens to these SDK events internally. No manual setup needed.
| `appBarOptions` | `List?` | `null` | Additional widgets in the app bar (e.g., call buttons, menu) |
| `hideUserStatus` | `bool?` | `false` | Hide online/offline status for users |
| `disableTypingIndicator` | `bool?` | `false` | Disable typing indicator display |
+| `pinnedMessagesVisibility` | `bool?` | `true` | Show the pinned-messages entry in the ⋯ overflow menu |
+| `onPinnedMessagesTap` | `VoidCallback?` | `null` | Handle the pinned-messages entry yourself instead of letting the Kit push the screen |
+| `onPinnedMessageItemTap` | `Function(BaseMessage)?` | `null` | Forwarded to the pinned list — fires when a pinned row is tapped |
+| `pinnedMessagesStyle` | `CometChatPinnedMessagesStyle?` | `null` | Styling for the pinned list the header opens |
+| `parentMessage` | `BaseMessage?` | `null` | Puts the header in thread mode and renders the subscription bell |
+| `threadSubscriptionVisibility` | `bool?` | `true` | Show the thread subscription bell (requires `parentMessage`) |
+
+---
+
+### Pinned messages
+
+When `pinnedMessagesVisibility` is not `false`, the ⋯ overflow menu carries an entry that opens the conversation's [Pinned Messages](/ui-kit/flutter/pinned-messages). The entry additionally requires the server Pin feature flag (`CometChat.isPinMessageEnabled()`), and never renders in thread mode — that is, when `parentMessage` is set.
+
+By default the Kit pushes the screen itself. Set `onPinnedMessagesTap` to take over presentation — a desktop layout that shows the list in a side panel rather than a pushed route, say. When the Kit does present it, `onPinnedMessageItemTap` forwards row taps so you can jump the message list.
+
+
+
+```dart
+CometChatMessageHeader(
+ group: group,
+ onPinnedMessageItemTap: (message) => _controller.jumpToMessage(message.id),
+)
+```
+
+
+
+### Thread subscription
+
+Passing the thread's root message as `parentMessage` puts the header in thread mode and renders a notification bell reflecting the live subscription state. The bell is gated on `UIKitSettings.enableThreadSubscription`, which is `false` by default — see the [Threaded Messages guide](/ui-kit/flutter/guide-threaded-messages).
---
diff --git a/ui-kit/flutter/message-list.mdx b/ui-kit/flutter/message-list.mdx
index 449301f06..783494637 100644
--- a/ui-kit/flutter/message-list.mdx
+++ b/ui-kit/flutter/message-list.mdx
@@ -204,6 +204,8 @@ The component listens to SDK message events internally. No manual setup needed.
| `onMessagesDelivered` / `onMessagesRead` | Updates receipt status via ValueNotifier |
| `onTypingStarted` / `onTypingEnded` | Updates typing indicator |
| `onMessageReactionAdded` / `onMessageReactionRemoved` | Updates reaction counts |
+| `onMessagePinned` / `onMessageUnpinned` | Restamps the message's pin state in-place |
+| `onMessageSaved` / `onMessageUnsaved` | Restamps the message's saved state (own devices only) |
| Connection reconnected | Triggers silent sync to fetch missed messages |
---
@@ -450,6 +452,11 @@ To stage and send multiple attachments, see [Message Composer](/ui-kit/flutter/m
| `hideDeleteMessageOption` | `false` | Hide "Delete Message" |
| `hideEditMessageOption` | `false` | Hide "Edit Message" |
| `hideMessageInfoOption` | `false` | Hide "Message Info" |
+| `hidePinMessageOption` | `false` | Hide "Pin" (shown while the message is unpinned) |
+| `hideUnpinMessageOption` | `false` | Hide "Unpin" (shown while the message is pinned) |
+| `hideSaveMessageOption` | `false` | Hide "Save" (shown while the message is unsaved) |
+| `hideUnsaveMessageOption` | `false` | Hide "Unsave" (shown while the message is saved) |
+| `hideThreadSubscriptionOption` | `false` | Hide "Subscribe/Unsubscribe to thread" (requires the feature gate, below) |
| `hideMessagePrivatelyOption` | `false` | Hide "Message Privately" |
| `hideReactionOption` | `false` | Hide "Reaction" |
| `hideReplyInThreadOption` | `false` | Hide "Reply in Thread" |
@@ -544,6 +551,59 @@ CometChatMessageList(
+### Pin or save a message
+
+The action menu offers **Pin** / **Unpin** and **Save** / **Unsave** on every message, wired internally to the SDK's [Pin Messages](/sdk/flutter/pin-messages) and [Save Messages](/sdk/flutter/save-messages) APIs. No setup is needed: the component performs the call, shows a confirmation toast, updates the bubble through the SDK's listener fan-out (so pins made by other members — and saves made on your other devices — land too), and maps the cap errors (`ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` / `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED`) to localized error copy with the server's limit interpolated.
+
+Pin respects the conversation's permission model (group pinning requires admin/moderator/owner scope), and both options hide themselves when the feature is disabled for the logged-in user.
+
+To hide them regardless, note that each state has its own flag: the menu shows **Pin** or **Unpin** depending on the message, so hiding pinning entirely takes `hidePinMessageOption` *and* `hideUnpinMessageOption`. Save works the same way.
+
+
+
+```dart
+CometChatMessageList(
+ user: user,
+ // both flags per feature — one per rendered state
+ hidePinMessageOption: true,
+ hideUnpinMessageOption: true,
+ hideSaveMessageOption: true,
+ hideUnsaveMessageOption: true,
+)
+```
+
+
+
+### Jump to a message
+
+`goToMessageId` is read once when the list mounts, so it cannot re-aim a list that is already on screen — the case you hit when a pinned or saved row is tapped and the conversation is already open.
+
+`CometChatMessageListController` covers that. Create it in your `State`, pass it as `controller`, and call `jumpToMessage`: the list scrolls to the message and highlights it, fetching the page around it first when it is not loaded yet.
+
+
+
+```dart
+final _controller = CometChatMessageListController();
+
+CometChatMessageList(
+ user: user,
+ controller: _controller,
+)
+
+// later — e.g. from CometChatPinnedMessages.onItemTap
+await _controller.jumpToMessage(message.id);
+```
+
+
+
+It attaches on mount and detaches on dispose. Calls made while no list is attached return `false` rather than throwing, and `isAttached` reports the current state.
+
+
+
+A different class of the same name existed in v5, where it exposed list state. That one is replaced by `MessageListBloc` — see [Upgrading from v5](/ui-kit/flutter/upgrading-from-v5). The v6 `CometChatMessageListController` does only imperative jumps.
+
+
+
### Mark a message as unread
Expose the "Mark as Unread" option in the long-press menu:
diff --git a/ui-kit/flutter/pinned-messages.mdx b/ui-kit/flutter/pinned-messages.mdx
new file mode 100644
index 000000000..3d69453bf
--- /dev/null
+++ b/ui-kit/flutter/pinned-messages.mdx
@@ -0,0 +1,96 @@
+---
+title: "Pinned Messages"
+description: "A screen listing every pinned message in one conversation, with an unpin affordance and tap-to-jump."
+---
+
+`CometChatPinnedMessages` lists the pinned messages of a single conversation. Rows are read-only previews ordered newest-pinned-first; each carries an Unpin affordance (permission-gated) and reports taps through `onItemTap` so the host can jump its message list to the tapped message.
+
+---
+
+## Where It Fits
+
+The screen opens on the **nearest** `Navigator`, the same navigation threads use. On a desktop side-by-side layout that replaces only the chat column; on mobile it is a full-screen route.
+
+The usual entry point is the pinned-messages item in `CometChatMessageHeader`'s ⋯ overflow menu, which pushes this screen for you. See [Message Header](/ui-kit/flutter/message-header) for the props that control it.
+
+---
+
+## Quick Start
+
+Either call the `show` helper, which pushes the screen with the kit's own transition, or embed the widget directly.
+
+
+
+```dart
+CometChatPinnedMessages.show(
+ context,
+ group: group, // or: user: user
+ onItemTap: (message) => _controller.jumpToMessage(message.id),
+);
+```
+
+
+
+Exactly one of `user` or `group` must be passed — the constructor asserts on it.
+
+---
+
+## Actions and Events
+
+### Callback Methods
+
+| Property | Description |
+| --- | --- |
+| `onItemTap` | Fires when a row is tapped, **after** the screen pops. Use it to jump the message list to that message. |
+
+Pair it with [`CometChatMessageListController.jumpToMessage`](/ui-kit/flutter/message-list) to land on the message without remounting the list.
+
+### Real-Time Updates (Automatic)
+
+The list stays current without a refetch. No setup needed.
+
+| Source | Internal behavior |
+| --- | --- |
+| `ccMessagePinned` / `ccMessageUnpinned` (UI Kit events) | Adds or drops the row for actions taken elsewhere in the app |
+| `onMessagePinned` / `onMessageUnpinned` (SDK listener) | Reflects pins and unpins made by other members |
+
+---
+
+## Functionality
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `user` | `User?` | - | 1:1 conversation scope. Mutually exclusive with `group`. |
+| `group` | `Group?` | - | Group conversation scope. Mutually exclusive with `user`. |
+| `onItemTap` | `Function(BaseMessage)?` | - | Fires on row tap, after the screen pops |
+| `hideUnpinOption` | `bool?` | `false` | Hides the per-row unpin affordance even when the user has permission |
+| `showBackButton` | `bool` | `true` | Toggles the header's close affordance. Turn it off when a host-owned panel supplies its own. |
+| `hideAppBar` | `bool` | `false` | Drops the header entirely. Set it when the host already renders a title bar — a desktop side panel does, and two stacked headers is the result otherwise. |
+| `style` | `CometChatPinnedMessagesStyle?` | - | Styling overrides |
+
+---
+
+## Style
+
+`CometChatPinnedMessagesStyle` accepts `backgroundColor`, `appBarColor`, `titleTextStyle`, `itemTitleTextStyle`, `itemSubtitleTextStyle`, `itemDateTextStyle`, `iconColor`, `unpinIconColor`, `separatorColor` and `borderRadius`.
+
+
+
+```dart
+CometChatPinnedMessages(
+ group: group,
+ style: CometChatPinnedMessagesStyle(
+ unpinIconColor: Colors.redAccent,
+ ),
+)
+```
+
+
+
+---
+
+## Next Steps
+
+- [Saved Messages](/ui-kit/flutter/saved-messages)
+- [Message List](/ui-kit/flutter/message-list)
+- [Pin Messages (SDK)](/sdk/flutter/pin-messages)
diff --git a/ui-kit/flutter/saved-messages.mdx b/ui-kit/flutter/saved-messages.mdx
new file mode 100644
index 000000000..246eb32bf
--- /dev/null
+++ b/ui-kit/flutter/saved-messages.mdx
@@ -0,0 +1,92 @@
+---
+title: "Saved Messages"
+description: "A screen listing the logged-in user's saved messages across every conversation."
+---
+
+`CometChatSavedMessages` lists the logged-in user's saved (bookmarked) messages across **every** conversation — unlike [Pinned Messages](/ui-kit/flutter/pinned-messages), it is not scoped to one chat. Rows are read-only previews ordered newest-saved-first, each with a conversation-context line and an Unsave affordance.
+
+Saves are private to the logged-in user and sync across their own devices.
+
+---
+
+## Quick Start
+
+
+
+```dart
+Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => CometChatSavedMessages(
+ onItemTap: (message) {
+ // open the message's conversation, then jump to it
+ },
+ ),
+ ),
+);
+```
+
+
+
+Because a saved message can belong to any conversation, `onItemTap` usually has to open the conversation first and then jump — see [`jumpToMessage`](/ui-kit/flutter/message-list).
+
+---
+
+## Actions and Events
+
+### Callback Methods
+
+| Property | Description |
+| --- | --- |
+| `onItemTap` | Fires when a row is tapped. Use it to open the message's conversation and jump to it. |
+
+### Real-Time Updates (Automatic)
+
+| Source | Internal behavior |
+| --- | --- |
+| `ccMessageSaved` / `ccMessageUnsaved` (UI Kit events) | Adds or drops the row for actions taken elsewhere in the app |
+| `onMessageSaved` / `onMessageUnsaved` (SDK listener) | Reflects saves made on the user's other devices |
+
+---
+
+## Functionality
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `onItemTap` | `Function(BaseMessage)?` | - | Fires on row tap |
+| `hideUnsaveOption` | `bool?` | `false` | Hides the per-row unsave affordance |
+| `popOnItemTap` | `bool` | `true` | Pops this screen **before** `onItemTap` fires. Right for a pushed mobile route; set `false` when the list lives in a persistent panel that should survive the jump. |
+| `showBackButton` | `bool` | `true` | Shows the app-bar back button |
+| `useCloseButton` | `bool` | `false` | Swaps the leading back arrow for a trailing ✕ with the title left-aligned — the chrome a desktop panel uses, where the screen is dismissed rather than navigated back from. Only read when `showBackButton` is `true`. |
+| `style` | `CometChatSavedMessagesStyle?` | - | Styling overrides |
+
+### Responsive presentation
+
+`popOnItemTap` and `useCloseButton` exist to let one component serve both layouts: a pushed mobile route that pops on jump, and a desktop panel that stays put and closes with a ✕.
+
+
+
+```dart
+CometChatSavedMessages(
+ onItemTap: _openSavedMessage,
+ popOnItemTap: !isDesktop,
+ useCloseButton: isDesktop,
+)
+```
+
+
+
+---
+
+## Style
+
+`CometChatSavedMessagesStyle` accepts `backgroundColor`, `appBarColor`, `titleTextStyle`, `itemTitleTextStyle`, `itemSubtitleTextStyle`, `itemContextTextStyle`, `itemDateTextStyle`, `iconColor`, `unsaveIconColor` and `separatorColor`.
+
+`itemContextTextStyle` styles the conversation-context line, which has no counterpart in the pinned list.
+
+---
+
+## Next Steps
+
+- [Pinned Messages](/ui-kit/flutter/pinned-messages)
+- [Message List](/ui-kit/flutter/message-list)
+- [Save Messages (SDK)](/sdk/flutter/save-messages)
diff --git a/ui-kit/flutter/threaded-messages-header.mdx b/ui-kit/flutter/threaded-messages-header.mdx
index 0d0c357b0..d616c63f6 100644
--- a/ui-kit/flutter/threaded-messages-header.mdx
+++ b/ui-kit/flutter/threaded-messages-header.mdx
@@ -90,6 +90,26 @@ Prerequisites: CometChat SDK initialized, a user logged in, and a valid `BaseMes
### Callback Methods
+#### `onThreadSubscriptionChange`
+
+Fires after the follow/unfollow toggle **succeeds**, with the parent message id and the new subscribed state. Use it to keep your own thread list in step — an unfollow also hard-deletes the thread's row server-side, so drop it locally.
+
+The bell renders only when `UIKitSettings.enableThreadSubscription` is `true`; it is `false` by default. Set `threadSubscriptionVisibility: false` to hide it when the header above already carries one.
+
+
+
+```dart
+CometChatThreadedHeader(
+ parentMessage: parentMessage,
+ loggedInUser: loggedInUser,
+ onThreadSubscriptionChange: (parentMessageId, subscribed) {
+ if (!subscribed) _removeThreadRow(parentMessageId);
+ },
+)
+```
+
+
+
#### `onBack`
Fires when the user presses the back button.
@@ -145,6 +165,8 @@ CometChatThreadedHeader(
| `showBackButton` | `bool?` | `true` | Toggle back button visibility |
| `title` | `String?` | `null` | Custom title text |
| `hideMessageComposer` | `bool?` | `false` | Hide the message composer |
+| `threadSubscriptionVisibility` | `bool?` | `true` | Show the follow/unfollow bell. Requires `UIKitSettings.enableThreadSubscription`. |
+| `onThreadSubscriptionChange` | `Function(int, bool)?` | `null` | Called after a successful toggle with the parent message id and the new state |
---