diff --git a/docs.json b/docs.json
index cb43da08b..7f33f7c67 100644
--- a/docs.json
+++ b/docs.json
@@ -1839,6 +1839,8 @@
"ui-kit/android/call-buttons",
"ui-kit/android/call-logs",
"ui-kit/android/search",
+ "ui-kit/android/pinned-messages",
+ "ui-kit/android/saved-messages",
"ui-kit/android/ai-assistant-chat-history",
"ui-kit/android/notification-feed"
]
@@ -1855,6 +1857,8 @@
"pages": [
"ui-kit/android/guide-overview",
"ui-kit/android/guide-threaded-messages",
+ "ui-kit/android/guide-thread-subscription",
+ "ui-kit/android/guide-pin-and-save-messages",
"ui-kit/android/guide-block-unblock-user",
"ui-kit/android/guide-new-chat",
"ui-kit/android/guide-message-privately",
@@ -4176,10 +4180,14 @@
"sdk/android/v5/additional-message-filtering",
"sdk/android/v5/retrieve-conversations",
"sdk/android/v5/threaded-messages",
+ "sdk/android/v5/thread-subscription",
"sdk/android/v5/edit-message",
"sdk/android/v5/delete-message",
"sdk/android/v5/flag-message",
+ "sdk/android/v5/pin-message",
+ "sdk/android/v5/save-message",
"sdk/android/v5/delete-conversation",
+ "sdk/android/v5/pin-conversation",
"sdk/android/v5/typing-indicators",
"sdk/android/v5/delivery-read-receipts",
"sdk/android/v5/transient-messages",
diff --git a/sdk/android/v5/additional-message-filtering.mdx b/sdk/android/v5/additional-message-filtering.mdx
index 6532fb806..fa687c2bb 100644
--- a/sdk/android/v5/additional-message-filtering.mdx
+++ b/sdk/android/v5/additional-message-filtering.mdx
@@ -1270,3 +1270,97 @@ val UID = "cometchat-uid-1"
+
+## Pinned messages
+
+*In other words, how do I fetch the pinned messages of a conversation*
+
+This can be achieved by setting the pinned flag to true using the `setPinned()` method. Setting a `UID` or a `GUID` is mandatory for this filter — pinned messages are always scoped to a single conversation. The returned list is sorted by the time of pinning, most recently pinned first.
+
+
+
+```java
+String UID = "cometchat-uid-1";
+
+MessagesRequest messagesRequest = new MessagesRequest.MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setUID(UID)
+ .build();
+```
+
+
+
+
+```java
+String GUID = "cometchat-guid-1";
+
+MessagesRequest messagesRequest = new MessagesRequest.MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setGUID(GUID)
+ .build();
+```
+
+
+
+
+```kotlin
+val UID = "cometchat-uid-1"
+
+val messagesRequest = MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setUID(UID)
+ .build()
+```
+
+
+
+
+```kotlin
+val GUID = "cometchat-guid-1"
+
+val messagesRequest = MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setGUID(GUID)
+ .build()
+```
+
+
+
+
+
+For the full pin workflow — pinning, unpinning, limits and real-time events — see [Pin A Message](/sdk/android/v5/pin-message).
+
+## Saved messages
+
+*In other words, how do I fetch the messages the logged-in user has saved*
+
+This can be achieved by setting the saved flag to true using the `setSaved()` method. Saved messages are private to the logged-in user and span all of their conversations, so this filter must **not** be combined with `setUID()` or `setGUID()`. The returned list is sorted by the time of saving, most recently saved first.
+
+
+
+```java
+MessagesRequest messagesRequest = new MessagesRequest.MessagesRequestBuilder()
+ .setSaved(true)
+ .setLimit(50)
+ .build();
+```
+
+
+
+
+```kotlin
+val messagesRequest = MessagesRequestBuilder()
+ .setSaved(true)
+ .setLimit(50)
+ .build()
+```
+
+
+
+
+
+For the full save workflow — saving, unsaving, limits and real-time events — see [Save A Message](/sdk/android/v5/save-message).
diff --git a/sdk/android/v5/pin-conversation.mdx b/sdk/android/v5/pin-conversation.mdx
new file mode 100644
index 000000000..415d10b5d
--- /dev/null
+++ b/sdk/android/v5/pin-conversation.mdx
@@ -0,0 +1,283 @@
+---
+title: "Pin A Conversation"
+---
+
+Let users keep their most important chats at the top of the list. Pinning a conversation is **per-user** — it changes the order of the acting user's own conversation list and is synced across their devices. A conversation can also be pinned globally for everyone by the app itself (a system pin). Let's see how to work with pinned conversations in CometChat's Android SDK.
+
+
+
+Pinning a conversation with the SDK requires the Pin Conversation feature to be enabled for your app. You can check its availability at runtime using the [feature flag](#feature-availability).
+
+
+
+## Pin a Conversation
+
+To pin a conversation, use the `pinConversation` method. Pass the `UID` of the other user (for a one-on-one conversation) or the `GUID` of the group, along with the matching conversation type. On success, the callback returns the updated `Conversation` with its pin attributes set.
+
+
+
+```java
+String UID = "cometchat-uid-1";
+
+CometChat.pinConversation(UID, CometChatConstants.CONVERSATION_TYPE_USER,
+ new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(Conversation conversation) {
+ Log.d(TAG, "Conversation pinned at: " + conversation.getPinnedAt());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to pin conversation: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```java
+String GUID = "cometchat-guid-1";
+
+CometChat.pinConversation(GUID, CometChatConstants.CONVERSATION_TYPE_GROUP,
+ new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(Conversation conversation) {
+ Log.d(TAG, "Conversation pinned at: " + conversation.getPinnedAt());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to pin conversation: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val UID = "cometchat-uid-1"
+
+CometChat.pinConversation(UID, CometChatConstants.CONVERSATION_TYPE_USER,
+ object : CometChat.CallbackListener() {
+ override fun onSuccess(conversation: Conversation?) {
+ Log.d(TAG, "Conversation pinned at: ${conversation?.pinnedAt}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to pin conversation: ${e?.message}")
+ }
+})
+```
+
+
+
+
+```kotlin
+val GUID = "cometchat-guid-1"
+
+CometChat.pinConversation(GUID, CometChatConstants.CONVERSATION_TYPE_GROUP,
+ object : CometChat.CallbackListener() {
+ override fun onSuccess(conversation: Conversation?) {
+ Log.d(TAG, "Conversation pinned at: ${conversation?.pinnedAt}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to pin conversation: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+## Unpin a Conversation
+
+To unpin a conversation, use the `unpinConversation` method with the same parameters. On success, the callback returns the updated `Conversation` with its pin attributes cleared.
+
+
+
+```java
+String UID = "cometchat-uid-1";
+
+CometChat.unpinConversation(UID, CometChatConstants.CONVERSATION_TYPE_USER,
+ new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(Conversation conversation) {
+ Log.d(TAG, "Conversation unpinned. isPinned: " + conversation.isPinned());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to unpin conversation: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val UID = "cometchat-uid-1"
+
+CometChat.unpinConversation(UID, CometChatConstants.CONVERSATION_TYPE_USER,
+ object : CometChat.CallbackListener() {
+ override fun onSuccess(conversation: Conversation?) {
+ Log.d(TAG, "Conversation unpinned. isPinned: ${conversation?.isPinned}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to unpin conversation: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+
+
+A user cannot unpin a **system pin** (a conversation pinned globally by the app). Use `isSystemPinned()` to detect this case and hide the unpin action.
+
+
+
+## Fetch Pinned Conversations
+
+The default conversations list already orders pinned conversations at the top. To fetch **only** pinned conversations, use the `setPinnedBy()` filter of the `ConversationsRequestBuilder`.
+
+| Value | Description |
+| --------------- | -------------------------------------------------------- |
+| `"me"` | Conversations pinned by the logged-in user. |
+| `"system"` | Conversations pinned globally by the app (system pins). |
+| `"system,me"` | Both. |
+
+
+
+```java
+ConversationsRequest conversationsRequest = new ConversationsRequest.ConversationsRequestBuilder()
+ .setPinnedBy("system,me")
+ .setLimit(30)
+ .build();
+
+conversationsRequest.fetchNext(new CometChat.CallbackListener>() {
+ @Override
+ public void onSuccess(List conversations) {
+ Log.d(TAG, "Pinned conversations: " + conversations.size());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Fetch failed: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val conversationsRequest = ConversationsRequest.ConversationsRequestBuilder()
+ .setPinnedBy("system,me")
+ .setLimit(30)
+ .build()
+
+conversationsRequest.fetchNext(object : CometChat.CallbackListener>() {
+ override fun onSuccess(conversations: List?) {
+ Log.d(TAG, "Pinned conversations: ${conversations?.size}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Fetch failed: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+## Check if a Conversation is Pinned
+
+Every fetched `Conversation` carries its pin state.
+
+| Method | Description |
+| ------------------ | ------------------------------------------------------------------------------------------------ |
+| `isPinned()` | Returns `true` if the conversation is pinned for the logged-in user (or globally). |
+| `isSystemPinned()` | Returns `true` if the conversation was pinned globally by the app (`pinnedBy` is `app_system`). |
+| `getPinnedAt()` | The timestamp at which the conversation was pinned. `0` when it is not pinned. |
+| `getPinnedBy()` | The `UID` of the pinner, or `app_system` for a system pin. |
+
+## Real-time Conversation Pin Events
+
+Register a `ConversationListener` to be notified when a conversation is pinned or unpinned, so your list can reorder without a refetch.
+
+Today these callbacks fire on the **acting user's device** when a pin or unpin succeeds. Delivery to the user's other devices activates once server-side real-time delivery for conversation-pin events is rolled out — until then, other sessions pick up the change on their next conversations fetch.
+
+
+
+```java
+private String listenerID = "UNIQUE_LISTENER_ID";
+
+CometChat.addConversationListener(listenerID, new CometChat.ConversationListener() {
+ @Override
+ public void onConversationPinned(Conversation conversation) {
+ Log.d(TAG, "Conversation pinned: " + conversation.getConversationId());
+ }
+
+ @Override
+ public void onConversationUnpinned(Conversation conversation) {
+ Log.d(TAG, "Conversation unpinned: " + conversation.getConversationId());
+ }
+});
+```
+
+
+
+
+```kotlin
+val listenerID = "UNIQUE_LISTENER_ID"
+
+CometChat.addConversationListener(listenerID, object : CometChat.ConversationListener() {
+ override fun onConversationPinned(conversation: Conversation) {
+ Log.d(TAG, "Conversation pinned: ${conversation.conversationId}")
+ }
+
+ override fun onConversationUnpinned(conversation: Conversation) {
+ Log.d(TAG, "Conversation unpinned: ${conversation.conversationId}")
+ }
+})
+```
+
+
+
+
+
+To stop listening, remove the listener with `CometChat.removeConversationListener(listenerID)`.
+
+## Feature Availability
+
+Check whether the Pin Conversation feature is enabled for your app before showing pin actions in your UI. The method is synchronous and safe to call from the UI layer.
+
+
+
+```java
+if (CometChat.isPinConversationEnabled()) {
+ // show the Pin conversation option
+}
+```
+
+
+
+
+```kotlin
+if (CometChat.isPinConversationEnabled()) {
+ // show the Pin conversation option
+}
+```
+
+
+
+
diff --git a/sdk/android/v5/pin-message.mdx b/sdk/android/v5/pin-message.mdx
new file mode 100644
index 000000000..0047bb754
--- /dev/null
+++ b/sdk/android/v5/pin-message.mdx
@@ -0,0 +1,347 @@
+---
+title: "Pin A Message"
+---
+
+Keep important messages easy to find by pinning them to a conversation. A pinned message is visible to **all participants** of the conversation, along with who pinned it and when. Users can pin messages, unpin them, and fetch all pinned messages of a conversation. You can also listen to pin events in real-time. Let's see how to work with pinned messages in CometChat's Android SDK.
+
+
+
+Pinning a message with the SDK requires the Pin Message feature to be enabled for your app. You can check its availability at runtime using the [feature flag](#feature-availability).
+
+
+
+## Pin a Message
+
+To pin a message, use the `pinMessage` method and pass the ID of the message to be pinned. On success, the callback returns the updated `BaseMessage` with its pin attributes set.
+
+
+
+```java
+long messageId = 1;
+
+CometChat.pinMessage(messageId, new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(BaseMessage message) {
+ Log.d(TAG, "Message pinned at: " + message.getPinnedAt());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to pin message: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val messageId = 1L
+
+CometChat.pinMessage(messageId, object : CometChat.CallbackListener() {
+ override fun onSuccess(message: BaseMessage?) {
+ Log.d(TAG, "Message pinned at: ${message?.pinnedAt}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to pin message: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+
+
+In a **group conversation**, the CometChat UI Kits show the Pin/Unpin option only to participants with the **Admin** or **Moderator** scope, or the group **owner**. This gate is applied client-side — the SDK does not currently enforce roles on the server, so apply your own role check if you build custom pin UI. Every participant can see pinned messages. In a **one-on-one conversation**, both participants can pin and unpin. Deleted messages cannot be pinned; deleting a pinned message automatically unpins it.
+
+
+
+## Unpin a Message
+
+To unpin a message, use the `unpinMessage` method. Any participant with pin permission can unpin a message — not just the user who originally pinned it. On success, the callback returns the updated `BaseMessage` with its pin attributes cleared.
+
+
+
+```java
+long messageId = 1;
+
+CometChat.unpinMessage(messageId, new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(BaseMessage message) {
+ Log.d(TAG, "Message unpinned. isPinned: " + message.isPinned());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to unpin message: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val messageId = 1L
+
+CometChat.unpinMessage(messageId, object : CometChat.CallbackListener() {
+ override fun onSuccess(message: BaseMessage?) {
+ Log.d(TAG, "Message unpinned. isPinned: ${message?.isPinned}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to unpin message: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+## Fetch Pinned Messages
+
+To fetch all pinned messages of a conversation, create a `MessagesRequest` with the `setPinned(true)` filter of the `MessagesRequestBuilder`. Setting a `UID` (for a one-on-one conversation) or a `GUID` (for a group) is **mandatory** — exactly one of the two. The returned list is sorted by the time of pinning, most recently pinned first.
+
+
+
+```java
+String UID = "cometchat-uid-1";
+
+MessagesRequest messagesRequest = new MessagesRequest.MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setUID(UID)
+ .build();
+
+messagesRequest.fetchNext(new CometChat.CallbackListener>() {
+ @Override
+ public void onSuccess(List messages) {
+ Log.d(TAG, "Pinned messages: " + messages.size());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Pinned messages fetch failed: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```java
+String GUID = "cometchat-guid-1";
+
+MessagesRequest messagesRequest = new MessagesRequest.MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setGUID(GUID)
+ .build();
+
+messagesRequest.fetchNext(new CometChat.CallbackListener>() {
+ @Override
+ public void onSuccess(List messages) {
+ Log.d(TAG, "Pinned messages: " + messages.size());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Pinned messages fetch failed: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val UID = "cometchat-uid-1"
+
+val messagesRequest = MessagesRequest.MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setUID(UID)
+ .build()
+
+messagesRequest.fetchNext(object : CometChat.CallbackListener>() {
+ override fun onSuccess(messages: List?) {
+ Log.d(TAG, "Pinned messages: ${messages?.size}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Pinned messages fetch failed: ${e?.message}")
+ }
+})
+```
+
+
+
+
+```kotlin
+val GUID = "cometchat-guid-1"
+
+val messagesRequest = MessagesRequest.MessagesRequestBuilder()
+ .setPinned(true)
+ .setLimit(50)
+ .setGUID(GUID)
+ .build()
+
+messagesRequest.fetchNext(object : CometChat.CallbackListener>() {
+ override fun onSuccess(messages: List?) {
+ Log.d(TAG, "Pinned messages: ${messages?.size}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Pinned messages fetch failed: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+## Check if a Message is Pinned
+
+Every fetched or received message carries its pin state on the `BaseMessage` itself.
+
+| Method | Description |
+| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `isPinned()` | Returns `true` if the message is currently pinned in its conversation. |
+| `getPinnedAt()` | The timestamp at which the message was pinned. `0` when the message is not pinned. |
+| `getPinnedBy()` | The `UID` of the user who most recently pinned the message. The value `app_system` indicates a pin applied by the app itself (a system pin). |
+
+
+
+```java
+if (message.isPinned()) {
+ Log.d(TAG, "Pinned by " + message.getPinnedBy() + " at " + message.getPinnedAt());
+}
+```
+
+
+
+
+```kotlin
+if (message.isPinned) {
+ Log.d(TAG, "Pinned by ${message.pinnedBy} at ${message.pinnedAt}")
+}
+```
+
+
+
+
+
+
+
+Editing a message preserves its pin. A message stores only its most recent pinner in `getPinnedBy()`.
+
+
+
+## Real-time Pin Events
+
+Register a `MessageListener` and override the pin callbacks. Each event delivers the full updated `BaseMessage`, so you can directly replace the message in your list.
+
+Today these callbacks fire on the **acting user's device** when a pin or unpin succeeds. Delivery to other participants activates once server-side real-time delivery for pin events is rolled out — until then, other clients pick up pin changes on their next message fetch.
+
+
+
+```java
+private String listenerID = "UNIQUE_LISTENER_ID";
+
+CometChat.addMessageListener(listenerID, new CometChat.MessageListener() {
+ @Override
+ public void onMessagePinned(BaseMessage message) {
+ Log.d(TAG, "Message pinned: " + message.getId());
+ }
+
+ @Override
+ public void onMessageUnpinned(BaseMessage message) {
+ Log.d(TAG, "Message unpinned: " + message.getId());
+ }
+});
+```
+
+
+
+
+```kotlin
+val listenerID = "UNIQUE_LISTENER_ID"
+
+CometChat.addMessageListener(listenerID, object : CometChat.MessageListener() {
+ override fun onMessagePinned(message: BaseMessage) {
+ Log.d(TAG, "Message pinned: ${message.id}")
+ }
+
+ override fun onMessageUnpinned(message: BaseMessage) {
+ Log.d(TAG, "Message unpinned: ${message.id}")
+ }
+})
+```
+
+
+
+
+
+To stop listening, remove the listener with `CometChat.removeMessageListener(listenerID)`.
+
+## Pin Limit
+
+A conversation can hold a limited number of pinned messages (100 by default). When the limit is exceeded, the SDK surfaces the server error through `onError`, and the applicable limit can be read programmatically from the exception — never hard-code it.
+
+
+
+```java
+@Override
+public void onError(CometChatException e) {
+ Object limit = e.getErrorParams() != null ? e.getErrorParams().get("limit") : null;
+ if (limit != null) {
+ Log.e(TAG, "You can pin up to " + limit + " messages in a conversation.");
+ }
+}
+```
+
+
+
+
+```kotlin
+override fun onError(e: CometChatException?) {
+ val limit = e?.errorParams?.get("limit")
+ if (limit != null) {
+ Log.e(TAG, "You can pin up to $limit messages in a conversation.")
+ }
+}
+```
+
+
+
+
+
+## Feature Availability
+
+Check whether the Pin Message feature is enabled for your app before showing pin actions in your UI. The method is synchronous and safe to call from the UI layer.
+
+
+
+```java
+if (CometChat.isPinMessageEnabled()) {
+ // show the Pin option
+}
+```
+
+
+
+
+```kotlin
+if (CometChat.isPinMessageEnabled()) {
+ // show the Pin option
+}
+```
+
+
+
+
diff --git a/sdk/android/v5/real-time-listeners.mdx b/sdk/android/v5/real-time-listeners.mdx
index 78a3620ee..e0d0eab6d 100644
--- a/sdk/android/v5/real-time-listeners.mdx
+++ b/sdk/android/v5/real-time-listeners.mdx
@@ -175,6 +175,10 @@ The `MessageListener` class provides you with live events related to messages. B
| `onMessagesRead(MessageReceipt messageReceipt)` | This event is triggered when a set of messages are marked as read for any particular conversation. |
| `onMessageEdited(BaseMessage message)` | This method is triggered when a particular message has been edited in a user/group conversation. |
| `onMessageDeleted(BaseMessage message)` | This event is triggered when a particular message is deleted in a user/group conversation. |
+| `onMessagePinned(BaseMessage message)` | This event is triggered when a message is pinned in a user/group conversation. |
+| `onMessageUnpinned(BaseMessage message)` | This event is triggered when a message is unpinned in a user/group conversation. |
+| `onMessageSaved(BaseMessage message)` | This event is triggered when the logged-in user saves a message (private — never delivered to other participants). |
+| `onMessageUnsaved(BaseMessage message)` | This event is triggered when the logged-in user unsaves a message (private — never delivered to other participants). |
| `onInteractiveMessageReceived(InteractiveMessage message)` | This event is triggered when an Interactive Message is received. |
| `onInteractionGoalCompleted(InteractionReceipt receipt)` | This event is triggered when an interaction Goal is achieved. |
| `onTransientMessageReceived(TransientMessage transientMessage)` | This event is triggered when a Transient Message is received. |
@@ -354,6 +358,102 @@ where `UNIQUE_LISTENER_ID` is the unique identifier for the listener. Please mak
Once the activity/fragment where the `MessageListener` is declared is not in use, you need to remove the listener using the `removeMessageListener()` method which takes the id of the listener to be removed as the parameter. We suggest you call this method in the `onPause()` method of the activity/fragment.
+## Conversation Listener
+
+The `ConversationListener` class provides you with live events related to conversations. Below are the callback methods provided by the `ConversationListener` class.
+
+| Method | Information |
+| ------------------------------------------------- | ---------------------------------------------------------------------------- |
+| `onConversationPinned(Conversation conversation)` | This event is triggered when a conversation is pinned for the logged-in user. |
+| `onConversationUnpinned(Conversation conversation)` | This event is triggered when a conversation is unpinned for the logged-in user. |
+
+To add the `ConversationListener`, you need to use the `addConversationListener()` method provided by the `CometChat` class.
+
+
+
+```java
+CometChat.addConversationListener(UNIQUE_LISTENER_ID, new CometChat.ConversationListener() {
+ @Override
+ public void onConversationPinned(Conversation conversation) {
+
+ }
+
+ @Override
+ public void onConversationUnpinned(Conversation conversation) {
+
+ }
+});
+```
+
+
+
+
+```kotlin
+CometChat.addConversationListener(UNIQUE_LISTENER_ID, object : CometChat.ConversationListener() {
+ override fun onConversationPinned(conversation: Conversation) {
+
+ }
+
+ override fun onConversationUnpinned(conversation: Conversation) {
+
+ }
+})
+```
+
+
+
+
+
+Once you have successfully registered the listener and no longer wish to receive any events, you need to remove the listener using the `removeConversationListener()` method with the same `UNIQUE_LISTENER_ID`.
+
+## Thread Listener
+
+The `ThreadListener` class provides you with live events related to thread subscriptions and threaded replies. Below are the callback methods provided by the `ThreadListener` class.
+
+| Method | Information |
+| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| `onThreadSubscriptionChanged(ThreadSubscriptionEvent event)` | This event is triggered when the logged-in user's subscription state for a thread changes. |
+| `onThreadReplyReceived(ThreadReplyEvent event)` | This event is triggered when a reply is received in a thread. |
+
+To add the `ThreadListener`, you need to use the `addThreadListener()` method provided by the `CometChat` class.
+
+
+
+```java
+CometChat.addThreadListener(UNIQUE_LISTENER_ID, new CometChat.ThreadListener() {
+ @Override
+ public void onThreadSubscriptionChanged(ThreadSubscriptionEvent event) {
+
+ }
+
+ @Override
+ public void onThreadReplyReceived(ThreadReplyEvent event) {
+
+ }
+});
+```
+
+
+
+
+```kotlin
+CometChat.addThreadListener(UNIQUE_LISTENER_ID, object : CometChat.ThreadListener() {
+ override fun onThreadSubscriptionChanged(event: ThreadSubscriptionEvent) {
+
+ }
+
+ override fun onThreadReplyReceived(event: ThreadReplyEvent) {
+
+ }
+})
+```
+
+
+
+
+
+Once you have successfully registered the listener and no longer wish to receive any events, you need to remove the listener using the `removeThreadListener()` method with the same `UNIQUE_LISTENER_ID`. See [Thread Subscription](/sdk/android/v5/thread-subscription) for the full feature.
+
## AI Assistant Listener
The `AIAssistantListener` class provides you with real-time streaming events from AI Agent runs. These events are emitted during a run lifecycle and include tool calls, card generation, and text message streaming. For a complete overview of the event lifecycle, see [AI Agents](/sdk/android/ai-agents).
diff --git a/sdk/android/v5/save-message.mdx b/sdk/android/v5/save-message.mdx
new file mode 100644
index 000000000..0e98c4dbf
--- /dev/null
+++ b/sdk/android/v5/save-message.mdx
@@ -0,0 +1,260 @@
+---
+title: "Save A Message"
+---
+
+Let users bookmark messages for later. Saving a message is **private to the logged-in user** — nobody else in the conversation can see it — and works **across conversations**: a user's saved messages from all of their chats appear in one list, synced across all of their devices. Let's see how to work with saved messages in CometChat's Android SDK.
+
+
+
+Saving a message with the SDK requires the Save Message feature to be enabled for your app. You can check its availability at runtime using the [feature flag](#feature-availability).
+
+
+
+## Save a Message
+
+To save a message, use the `saveMessage` method and pass the ID of the message. On success, the callback returns the updated `BaseMessage` with its `savedAt` attribute set.
+
+
+
+```java
+long messageId = 1;
+
+CometChat.saveMessage(messageId, new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(BaseMessage message) {
+ Log.d(TAG, "Message saved at: " + message.getSavedAt());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to save message: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val messageId = 1L
+
+CometChat.saveMessage(messageId, object : CometChat.CallbackListener() {
+ override fun onSuccess(message: BaseMessage?) {
+ Log.d(TAG, "Message saved at: ${message?.savedAt}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to save message: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+
+
+Unlike pinning, saving has no role restrictions — every user can save any message they have access to. Deleted messages cannot be saved.
+
+
+
+## Unsave a Message
+
+To remove a message from the user's saved list, use the `unsaveMessage` method. On success, the callback returns the updated `BaseMessage` with its `savedAt` attribute cleared.
+
+
+
+```java
+long messageId = 1;
+
+CometChat.unsaveMessage(messageId, new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(BaseMessage message) {
+ Log.d(TAG, "Message unsaved. isSaved: " + message.isSaved());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to unsave message: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val messageId = 1L
+
+CometChat.unsaveMessage(messageId, object : CometChat.CallbackListener() {
+ override fun onSuccess(message: BaseMessage?) {
+ Log.d(TAG, "Message unsaved. isSaved: ${message?.isSaved}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to unsave message: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+## Fetch Saved Messages
+
+To fetch all messages the logged-in user has saved, create a `MessagesRequest` with the `setSaved(true)` filter of the `MessagesRequestBuilder`. Because saved messages are user-level and span conversations, you must **not** set a `UID` or `GUID`. The returned list is sorted by the time of saving, most recently saved first.
+
+
+
+```java
+MessagesRequest messagesRequest = new MessagesRequest.MessagesRequestBuilder()
+ .setSaved(true)
+ .setLimit(50)
+ .build();
+
+messagesRequest.fetchNext(new CometChat.CallbackListener>() {
+ @Override
+ public void onSuccess(List messages) {
+ Log.d(TAG, "Saved messages: " + messages.size());
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Saved messages fetch failed: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val messagesRequest = MessagesRequest.MessagesRequestBuilder()
+ .setSaved(true)
+ .setLimit(50)
+ .build()
+
+messagesRequest.fetchNext(object : CometChat.CallbackListener>() {
+ override fun onSuccess(messages: List?) {
+ Log.d(TAG, "Saved messages: ${messages?.size}")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Saved messages fetch failed: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+
+
+If the user loses access to a conversation (for example, they are removed from a group), messages saved from it are cleaned up and no longer returned.
+
+
+
+## Check if a Message is Saved
+
+Every fetched message carries the logged-in user's save state on the `BaseMessage` itself. These values are **per-user**: the same message shows different values to different users.
+
+| Method | Description |
+| -------------- | --------------------------------------------------------------------------------------- |
+| `isSaved()` | Returns `true` if the logged-in user has saved this message. |
+| `getSavedAt()` | The timestamp at which the logged-in user saved the message. `0` when it is not saved. |
+
+
+
+```java
+if (message.isSaved()) {
+ Log.d(TAG, "Saved at " + message.getSavedAt());
+}
+```
+
+
+
+
+```kotlin
+if (message.isSaved) {
+ Log.d(TAG, "Saved at ${message.savedAt}")
+}
+```
+
+
+
+
+
+## Real-time Save Events
+
+Because saving is private, save events are never delivered to other participants. Register a `MessageListener` and override the save callbacks; each event delivers the full updated `BaseMessage`.
+
+Today these callbacks fire on the **acting device** when a save or unsave succeeds. Delivery to the user's other devices activates once server-side real-time delivery for save events is rolled out — until then, other sessions pick up save changes on their next fetch.
+
+
+
+```java
+private String listenerID = "UNIQUE_LISTENER_ID";
+
+CometChat.addMessageListener(listenerID, new CometChat.MessageListener() {
+ @Override
+ public void onMessageSaved(BaseMessage message) {
+ Log.d(TAG, "Message saved: " + message.getId());
+ }
+
+ @Override
+ public void onMessageUnsaved(BaseMessage message) {
+ Log.d(TAG, "Message unsaved: " + message.getId());
+ }
+});
+```
+
+
+
+
+```kotlin
+val listenerID = "UNIQUE_LISTENER_ID"
+
+CometChat.addMessageListener(listenerID, object : CometChat.MessageListener() {
+ override fun onMessageSaved(message: BaseMessage) {
+ Log.d(TAG, "Message saved: ${message.id}")
+ }
+
+ override fun onMessageUnsaved(message: BaseMessage) {
+ Log.d(TAG, "Message unsaved: ${message.id}")
+ }
+})
+```
+
+
+
+
+
+To stop listening, remove the listener with `CometChat.removeMessageListener(listenerID)`.
+
+## Feature Availability
+
+Check whether the Save Message feature is enabled for your app before showing save actions in your UI. The method is synchronous and safe to call from the UI layer.
+
+
+
+```java
+if (CometChat.isSaveMessageEnabled()) {
+ // show the Save option
+}
+```
+
+
+
+
+```kotlin
+if (CometChat.isSaveMessageEnabled()) {
+ // show the Save option
+}
+```
+
+
+
+
diff --git a/sdk/android/v5/thread-subscription.mdx b/sdk/android/v5/thread-subscription.mdx
new file mode 100644
index 000000000..75ea2bf38
--- /dev/null
+++ b/sdk/android/v5/thread-subscription.mdx
@@ -0,0 +1,330 @@
+---
+title: "Thread Subscription"
+---
+
+Give users Slack-style control over thread noise. A user can **subscribe** to a message thread to be notified of future replies, or **unsubscribe** from it to mute it. Users are automatically subscribed to a thread when they start it, reply in it, or are @-mentioned in it — and they can explicitly subscribe to any parent message, even one that has no replies yet. The SDK also exposes the list of threads a user participates in, so you can build a thread inbox. Let's see how to work with thread subscriptions in CometChat's Android SDK.
+
+
+
+Thread subscription builds on [Threaded Messages](/sdk/android/v5/threaded-messages). A thread is identified by the ID of its **parent message** — there is no separate thread ID.
+
+
+
+## Subscribe to a Thread
+
+To subscribe to a thread, use the `subscribeToThread` method with the ID of the thread's parent message. The call is **idempotent** — subscribing to a thread the user is already subscribed to succeeds silently. Subscribing to a message with zero replies is allowed; the user will be notified when the first reply arrives.
+
+
+
+```java
+long parentMessageId = 1;
+
+CometChat.subscribeToThread(parentMessageId, new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(String response) {
+ Log.d(TAG, "Subscribed to thread: " + response);
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to subscribe: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val parentMessageId = 1L
+
+CometChat.subscribeToThread(parentMessageId, object : CometChat.CallbackListener() {
+ override fun onSuccess(response: String?) {
+ Log.d(TAG, "Subscribed to thread: $response")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to subscribe: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+## Unsubscribe from a Thread
+
+To unsubscribe from a thread, use the `unsubscribeFromThread` method. This too is idempotent — unsubscribing from a thread the user is not subscribed to succeeds silently.
+
+
+
+```java
+long parentMessageId = 1;
+
+CometChat.unsubscribeFromThread(parentMessageId, new CometChat.CallbackListener() {
+ @Override
+ public void onSuccess(String response) {
+ Log.d(TAG, "Unsubscribed from thread: " + response);
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Failed to unsubscribe: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val parentMessageId = 1L
+
+CometChat.unsubscribeFromThread(parentMessageId, object : CometChat.CallbackListener() {
+ override fun onSuccess(response: String?) {
+ Log.d(TAG, "Unsubscribed from thread: $response")
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Failed to unsubscribe: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+
+
+Unsubscribing is **not sticky**. If the user replies in the thread again, or is @-mentioned in it, they are automatically re-subscribed. Do not promise users "you won't be notified about this thread again".
+
+
+
+## Get the Subscription State
+
+`getThreadSubscriptionState` returns the logged-in user's subscription state for a thread **synchronously** — it never makes a network call, never throws, and is safe to call from your UI while rendering.
+
+
+
+```java
+ThreadSubscriptionState state = CometChat.getThreadSubscriptionState(parentMessageId);
+
+switch (state) {
+ case SUBSCRIBED: /* render "Unsubscribe from thread" */ break;
+ case NOT_SUBSCRIBED: /* render "Subscribe to thread" */ break;
+ case UNKNOWN: /* render "Subscribe to thread" */ break;
+}
+```
+
+
+
+
+```kotlin
+when (CometChat.getThreadSubscriptionState(parentMessageId)) {
+ ThreadSubscriptionState.SUBSCRIBED -> { /* render "Unsubscribe from thread" */ }
+ ThreadSubscriptionState.NOT_SUBSCRIBED -> { /* render "Subscribe to thread" */ }
+ ThreadSubscriptionState.UNKNOWN -> { /* render "Subscribe to thread" */ }
+}
+```
+
+
+
+
+
+The state is a deliberate tri-state, not a boolean:
+
+| Value | Meaning |
+| ---------------- | ------------------------------------------------------------------------------------------------- |
+| `SUBSCRIBED` | The user is subscribed to this thread and will be notified of replies. |
+| `NOT_SUBSCRIBED` | The user is known not to be subscribed to this thread. |
+| `UNKNOWN` | The state has not been learned yet (for example, the message arrived live over the websocket). |
+
+
+
+Render `UNKNOWN` as the unsubscribed state (an enabled "Subscribe" control) — never as a spinner or a disabled control. The state is kept in an in-memory, per-login-session cache; it is cleared on login and logout, and nothing is persisted to disk.
+
+
+
+
+
+The cache is seeded **only** by message fetches that opt in with `withThreadSubscribed(true)` on the `MessagesRequestBuilder` — a plain fetch does not carry the subscription state, and `getThreadSubscriptionState` will keep returning `UNKNOWN`. Opt in on the requests that back your thread UI:
+
+```kotlin
+val messagesRequest = MessagesRequest.MessagesRequestBuilder()
+ .setUID(UID)
+ .setLimit(50)
+ .withThreadSubscribed(true)
+ .build()
+```
+
+(The CometChat UI Kit sets this flag internally, so this only concerns you when calling the SDK directly.)
+
+
+
+## Fetch the Threads a User Participates In
+
+To build a thread inbox — one row per thread the user is part of — create a `ThreadsRequest` using the `ThreadsRequestBuilder`. The list is the union of threads the user started, replied in, was mentioned in, or explicitly subscribed to. Every returned row is, by definition, a thread the user is subscribed to: **participation is subscription**, and unsubscribing removes the row.
+
+| Setting | Description |
+| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `setLimit(int value)` | Page size, validated between 1 and 1000. Defaults to 30 — thread rows are heavy (each carries a root message and a last reply). |
+| `setUid(String value)` | Scope the list to threads in the one-on-one conversation with this user. Mutually exclusive with `setGuid()`. |
+| `setGuid(String value)` | Scope the list to threads in this group. Mutually exclusive with `setUid()`. |
+| `setParticipatedByMe(boolean)` | Defaults to `true`. Only the threads the logged-in user participates in are returned. |
+
+
+
+```java
+ThreadsRequest threadsRequest = new ThreadsRequest.ThreadsRequestBuilder()
+ .setLimit(30)
+ .build();
+
+threadsRequest.fetchNext(new CometChat.CallbackListener>() {
+ @Override
+ public void onSuccess(List threads) {
+ for (MessageThread thread : threads) {
+ Log.d(TAG, "Thread " + thread.getParentMessageId()
+ + " has " + thread.getReplyCount() + " replies");
+ }
+ }
+
+ @Override
+ public void onError(CometChatException e) {
+ Log.e(TAG, "Threads fetch failed: " + e.getMessage());
+ }
+});
+```
+
+
+
+
+```kotlin
+val threadsRequest = ThreadsRequest.ThreadsRequestBuilder()
+ .setLimit(30)
+ .build()
+
+threadsRequest.fetchNext(object : CometChat.CallbackListener>() {
+ override fun onSuccess(threads: List?) {
+ threads?.forEach { thread ->
+ Log.d(TAG, "Thread ${thread.parentMessageId} has ${thread.replyCount} replies")
+ }
+ }
+
+ override fun onError(e: CometChatException?) {
+ Log.e(TAG, "Threads fetch failed: ${e?.message}")
+ }
+})
+```
+
+
+
+
+
+Call `fetchNext()` repeatedly to page forward; `hasMore()` tells you whether more pages exist. A `ThreadsRequest` is **single-use and forward-only** — there is no `fetchPrevious()`. To refresh the list from the top, build a new request from the builder and replace your list with its results. Calling `fetchNext()` while a fetch is already in flight fails with a request-in-progress error.
+
+### The MessageThread Model
+
+Each row is a `MessageThread`:
+
+| Method | Description |
+| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `getParentMessageId()` | The thread's identity — the ID of its root message. |
+| `getParentMessage()` | The root message as a full `BaseMessage`. |
+| `getReplyCount()` | Number of replies in the thread. |
+| `getLastReply()` | The most recent reply as a `BaseMessage`. `null` for a thread with no replies yet — expected, not an error. |
+| `getConversationId()` | The ID of the conversation the thread belongs to. |
+| `getReceiverType()` | `user` or `group`. |
+| `getReceiverUid()` | The raw `UID`/`GUID` of the conversation. Resolve the display name and avatar yourself via `CometChat.getUser()` / `CometChat.getGroup()`. |
+| `getSubscriptionState()` | Always `SUBSCRIBED` for rows in this list. |
+| `getUnreadReplyCount()` | Reserved for future use — currently `null` (unknown), which is not the same as `0`. |
+| `getUpdatedAt()` | An internal pagination cursor. **Do not sort your UI on it.** |
+
+
+
+To order rows in your UI, sort on `getLastReply().getSentAt()`, falling back to `getParentMessage().getSentAt()` for zero-reply threads — not on `getUpdatedAt()`.
+
+
+
+
+
+The list starts **empty** for every user when the feature launches — it fills up as users reply, get mentioned, and subscribe to threads. There is no historical backfill.
+
+
+
+## Real-time Thread Events
+
+Register a `ThreadListener` to keep your UI in sync as subscription state changes and replies arrive.
+
+
+
+```java
+private String listenerID = "UNIQUE_LISTENER_ID";
+
+CometChat.addThreadListener(listenerID, new CometChat.ThreadListener() {
+ @Override
+ public void onThreadSubscriptionChanged(ThreadSubscriptionEvent event) {
+ Log.d(TAG, "Thread " + event.getParentMessageId()
+ + " is now " + event.getSubscriptionState());
+ }
+
+ @Override
+ public void onThreadReplyReceived(ThreadReplyEvent event) {
+ Log.d(TAG, "New reply in thread " + event.getParentMessageId()
+ + ": " + event.getReply().getId());
+ }
+});
+```
+
+
+
+
+```kotlin
+val listenerID = "UNIQUE_LISTENER_ID"
+
+CometChat.addThreadListener(listenerID, object : CometChat.ThreadListener() {
+ override fun onThreadSubscriptionChanged(event: ThreadSubscriptionEvent) {
+ Log.d(TAG, "Thread ${event.parentMessageId} is now ${event.subscriptionState}")
+ }
+
+ override fun onThreadReplyReceived(event: ThreadReplyEvent) {
+ Log.d(TAG, "New reply in thread ${event.parentMessageId}: ${event.reply.id}")
+ }
+})
+```
+
+
+
+
+
+To stop listening, remove the listener with `CometChat.removeThreadListener(listenerID)`.
+
+- `onThreadSubscriptionChanged` fires when the logged-in user's subscription state for a thread changes on **this device** — after a successful subscribe/unsubscribe call, or after a threaded send auto-subscribes them.
+- `onThreadReplyReceived` fires for every incoming threaded message and for the user's own successful threaded sends. Use it to bump reply counts and re-sort your thread list.
+
+
+
+Registering a second listener with the same `listenerID` **replaces** the first one. Use distinct IDs for distinct screens. A subscribe or unsubscribe performed on the user's **other device** does not currently produce a real-time event on this one — the state self-corrects on the next message fetch, so refresh your thread list when the app returns to the foreground.
+
+
+
+## Notification Preferences
+
+The notification preference for replies gains a new value so users can be notified only for threads they are subscribed to: `SUBSCRIBE_TO_SUBSCRIBED_THREADS` in the `RepliesOptions` enum.
+
+| Value | Behavior |
+| -------------------------------- | ---------------------------------------------------------------- |
+| `DONT_SUBSCRIBE` | No notifications for thread replies. |
+| `SUBSCRIBE_TO_ALL` | Notifications for all thread replies. |
+| `SUBSCRIBE_TO_MENTIONS` | Notifications only for replies that mention the user. |
+| `SUBSCRIBE_TO_SUBSCRIBED_THREADS`| Notifications for replies in threads the user is subscribed to. |
+
+See [Notification Preferences](/notifications) for how to read and update a user's preferences.
+
+## Error Handling
+
+| Error | Meaning |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `ERR_MESSAGE_NO_ACCESS` | The user no longer has access to the message's conversation (for example, they left or were banned from the group). Treat the thread as inaccessible and remove its row. |
+| `ERR_MESSAGE_ID_NOT_FOUND` | The parent message does not exist (for example, it was deleted). |
diff --git a/sdk/android/v5/threaded-messages.mdx b/sdk/android/v5/threaded-messages.mdx
index 114e6f114..c86cc743f 100644
--- a/sdk/android/v5/threaded-messages.mdx
+++ b/sdk/android/v5/threaded-messages.mdx
@@ -241,3 +241,7 @@ messagesRequest.fetchPrevious(object : CallbackListener?>() {
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.
+
+## Subscribe to a Thread
+
+Users can subscribe to or unsubscribe from a thread to control whether they are notified about its replies, and you can fetch the list of threads a user participates in to build a thread inbox. See [Thread Subscription](/sdk/android/v5/thread-subscription).
diff --git a/ui-kit/android/components-overview.mdx b/ui-kit/android/components-overview.mdx
index 5b99dac80..70b3b71bb 100644
--- a/ui-kit/android/components-overview.mdx
+++ b/ui-kit/android/components-overview.mdx
@@ -48,6 +48,8 @@ Components communicate via `CometChatEvents` — a SharedFlow-based event bus. S
| `CometChatMessageList` | Message feed with reactions, receipts, threads | [Message List](/ui-kit/android/message-list) |
| `CometChatMessageComposer` | Rich input with attachments, mentions, voice | [Message Composer](/ui-kit/android/message-composer) |
| `CometChatThreadHeader` | Parent message bubble and reply count | [Thread Header](/ui-kit/android/threaded-messages-header) |
+| `CometChatPinnedMessages` | Full-screen list of a conversation's pinned messages | [Pinned Messages](/ui-kit/android/pinned-messages) |
+| `CometChatSavedMessages` | Full-screen, private list of the user's saved messages | [Saved Messages](/ui-kit/android/saved-messages) |
### Calling
diff --git a/ui-kit/android/conversations.mdx b/ui-kit/android/conversations.mdx
index 269d48ad8..1e71ad293 100644
--- a/ui-kit/android/conversations.mdx
+++ b/ui-kit/android/conversations.mdx
@@ -431,6 +431,7 @@ The component listens to these SDK events internally. No manual setup needed.
| `setSelectionMode(MULTIPLE)` | `selectionMode = MULTIPLE` | Enable selection mode |
| `setTitle("Chats")` | `title = "Chats"` | Custom toolbar title |
| `setSearchPlaceholderText("Search...")` | `searchPlaceholderText = "Search..."` | Search placeholder |
+| `setPinConversationOptionVisibility(View.GONE)` | — | Hide the built-in Pin/Unpin conversation option |
---
@@ -708,6 +709,18 @@ CometChatConversations(
+### Built-in Pin Conversation Option
+
+When the Pin Conversation feature is enabled for your app (`CometChatUIKit.isPinConversationEnabled()`), the long-press menu automatically includes **Pin conversation** / **Unpin conversation** — no wiring needed. Pinning applies immediately with a toast; unpinning asks for confirmation first. Pinned conversations display a pin indicator next to the timestamp and stay at the **top of the list**, holding their position even as new messages arrive in other chats. Hide the option with `setPinConversationOptionVisibility(View.GONE)`.
+
+To render a pinned-only list, pass a request builder with the pinned filter — see [Pin A Conversation (SDK)](/sdk/android/v5/pin-conversation):
+
+```kotlin lines
+conversations.setConversationsRequestBuilder(
+ ConversationsRequest.ConversationsRequestBuilder().setPinnedBy("system,me")
+)
+```
+
---
## Common Patterns
diff --git a/ui-kit/android/core-features.mdx b/ui-kit/android/core-features.mdx
index fe18b0a5d..3bf9d7ec0 100644
--- a/ui-kit/android/core-features.mdx
+++ b/ui-kit/android/core-features.mdx
@@ -136,6 +136,27 @@ Address specific users in a conversation by typing `@` to trigger mention sugges
| [CometChatMessageList](/ui-kit/android/message-list) | Renders mentions with distinct styling in the message flow. |
+## Pin & Save Messages
+
+Keep important messages in reach. Pinning highlights a message for **everyone** in the conversation; saving bookmarks it **privately** for the acting user, across all of their conversations. Both come with action-sheet options, bubble indicators, and dedicated full-screen views.
+
+| Component | Role |
+| --- | --- |
+| [CometChatMessageList](/ui-kit/android/message-list) | Provides the Pin/Unpin and Save/Unsave options and shows the bubble footer indicators. |
+| [CometChatPinnedMessages](/ui-kit/android/pinned-messages) | Full-screen list of a conversation's pinned messages. |
+| [CometChatSavedMessages](/ui-kit/android/saved-messages) | Full-screen, private list of the user's saved messages. |
+| [CometChatMessageHeader](/ui-kit/android/message-header) | Built-in "Pinned messages" menu entry point. |
+
+See the [Pin & Save Messages guide](/ui-kit/android/guide-pin-and-save-messages) for end-to-end wiring.
+
+## Pin Conversations
+
+Keep the chats that matter at the top. Users pin a conversation from the long-press menu; pinned conversations show a pin indicator and stay above the rest of the list.
+
+| Component | Role |
+| --- | --- |
+| [CometChatConversations](/ui-kit/android/conversations) | Provides the Pin/Unpin conversation option, the row indicator, and pinned-first ordering. |
+
## Rich Text Formatting
Rich Text Formatting allows users to style their messages with bold, italic, strikethrough, code, code blocks, blockquotes, ordered/unordered lists, and links. This brings richer expression to conversations and helps users emphasize key points.
@@ -162,6 +183,17 @@ Respond directly to a specific message, keeping conversations organized.
| [CometChatMessageComposer](/ui-kit/android/message-composer) | Allows composing messages within a thread. |
| [CometChatMessageList](/ui-kit/android/message-list) | Displays threaded messages in context. |
+## Thread Subscription
+
+Let users subscribe to or unsubscribe from a thread to control whether its replies notify them. Opt-in feature — enable it with `UIKitSettings.setEnableThreadSubscription(true)`.
+
+| Component | Role |
+| --- | --- |
+| [CometChatMessageList](/ui-kit/android/message-list) | Provides the Subscribe to thread / Unsubscribe from thread option in the message action sheet. |
+| [CometChatThreadHeader](/ui-kit/android/threaded-messages-header) | Shows the subscription bell on the thread view. |
+
+See the [Thread Subscription guide](/ui-kit/android/guide-thread-subscription) for setup and behavior.
+
## Quoted Replies
Reply to specific messages by selecting "Reply" from the message action menu, maintaining context in the conversation.
diff --git a/ui-kit/android/customization-text-formatters.mdx b/ui-kit/android/customization-text-formatters.mdx
index ad96f7922..738976545 100644
--- a/ui-kit/android/customization-text-formatters.mdx
+++ b/ui-kit/android/customization-text-formatters.mdx
@@ -26,8 +26,15 @@ The abstract class takes a `trackingCharacter` that triggers the formatter when
| `prepareComposerSpan(context, message, spannable)` | Apply spans to text in the message composer. |
| `prepareConversationSpan(context, message, spannable)` | Apply spans to the last message preview in the conversation list. |
| `handlePreMessageSend(context, message)` | Modify a message before it's sent (attach metadata, transform text). |
+| `getOriginalText(text)` | Strip this formatter's display markup back to the storable token form sent on the wire. Default is identity; override it when your formatter renders a token (e.g. a custom style tag) differently from how it is stored, so the token survives send and re-renders via the `prepare*Span` overrides. |
| `onItemClick(context, suggestionItem, user, group)` | Called when the user selects a suggestion item. |
+
+
+Custom formatters render **live in the composer while typing**, and their `prepare*Span` overrides are applied consistently across the message bubble, the reply/edit preview, and the conversation-list preview — register the formatter once and every surface picks it up.
+
+
+
### Suggestion System
| Method | Description |
diff --git a/ui-kit/android/customization-view-slots.mdx b/ui-kit/android/customization-view-slots.mdx
index 0c367e33c..0ae6b78ff 100644
--- a/ui-kit/android/customization-view-slots.mdx
+++ b/ui-kit/android/customization-view-slots.mdx
@@ -254,6 +254,9 @@ View slots are available on all list-based components:
| `CometChatCallLogs` | `CallLogsViewHolderListener` | `(CallLog) -> Unit` |
| `CometChatReactionList` | `ReactionListViewHolderListener` | `(Reaction) -> Unit` |
| `CometChatMessageHeader` | `MessageHeaderViewHolderListener` | `(User?, Group?) -> Unit` |
+| `CometChatMessageComposer` (rich-text toolbar trailing slot) | `RichTextToolbarTrailingViewListener` | `RowScope.(ComposerInputController) -> Unit` |
+
+The composer's trailing-toolbar slot additionally hands your view a live `ComposerInputController` for reading and mutating the input — see [Message Composer › Rich-Text Toolbar Trailing Buttons](/ui-kit/android/message-composer#rich-text-toolbar-trailing-buttons).
---
diff --git a/ui-kit/android/events.mdx b/ui-kit/android/events.mdx
index 6575197b6..c61d5b517 100644
--- a/ui-kit/android/events.mdx
+++ b/ui-kit/android/events.mdx
@@ -49,6 +49,7 @@ import com.cometchat.uikit.core.events.CometChatEvents
| `CometChatEvents.groupEvents` | `GroupEvent` | Group created, deleted, member changes |
| `CometChatEvents.userEvents` | `UserEvent` | User blocked, unblocked |
| `CometChatEvents.uiEvents` | `UIEvent` | Panel visibility, active chat changes |
+| `CometChatEvents.threadEvents` | `CometChatThreadEvent` | Thread subscription state changes |
## API reference
@@ -70,6 +71,10 @@ import com.cometchat.uikit.core.events.CometChatEvents
| `MessageEvent.CustomInteractiveReceived(message)` | Triggered when a custom interactive message is received. |
| `MessageEvent.InteractionGoalCompleted(message)` | Triggered when an interaction goal is completed. |
| `MessageEvent.SchedulerReceived(message)` | Triggered when a scheduler message is received. |
+| `MessageEvent.MessagePinned(message)` | Triggered when a message is pinned. |
+| `MessageEvent.MessageUnpinned(message)` | Triggered when a message is unpinned. |
+| `MessageEvent.MessageSaved(message)` | Triggered when the logged-in user saves a message. |
+| `MessageEvent.MessageUnsaved(message)` | Triggered when the logged-in user unsaves a message. |
**Collecting events:**
@@ -157,6 +162,32 @@ fun MessageEventsHandler() {
---
+### Thread Events
+
+`CometChatEvents.threadEvents` emits `CometChatThreadEvent` instances when the logged-in user subscribes to or unsubscribes from a message thread, so every surface showing a subscription control can stay in sync without a refetch.
+
+**Event types:**
+
+| Event | Description |
+| ----- | ----------- |
+| `CometChatThreadEvent.SubscriptionChanged(parentMessageId, subscriptionState, source)` | Triggered when the user's subscription state for a thread changes. `subscriptionState` is a `ThreadSubscriptionState` (`SUBSCRIBED` / `NOT_SUBSCRIBED` / `UNKNOWN`). |
+
+**Collecting events:**
+
+```kotlin
+lifecycleScope.launch {
+ CometChatEvents.threadEvents.collect { event ->
+ when (event) {
+ is CometChatThreadEvent.SubscriptionChanged -> {
+ // Update your subscription control for event.parentMessageId
+ }
+ }
+ }
+}
+```
+
+See the [Thread Subscription guide](/ui-kit/android/guide-thread-subscription) for the feature end to end.
+
### Call Events
`CometChatEvents.callEvents` emits `CallEvent` sealed class instances for call lifecycle changes.
diff --git a/ui-kit/android/guide-pin-and-save-messages.mdx b/ui-kit/android/guide-pin-and-save-messages.mdx
new file mode 100644
index 000000000..f29da0b88
--- /dev/null
+++ b/ui-kit/android/guide-pin-and-save-messages.mdx
@@ -0,0 +1,166 @@
+---
+title: "Pin & Save Messages"
+sidebarTitle: "Pin & Save Messages"
+description: "Add pinned messages, saved messages, and pinned conversations to your app with the built-in options, indicators, and screens."
+---
+
+## Overview
+
+Three related features help users keep track of what matters:
+
+| Feature | Scope | Visible to | Surfaces |
+| --- | --- | --- | --- |
+| **Pin Message** | One conversation | Everyone in it | Action-sheet option, bubble indicator, [Pinned Messages](/ui-kit/android/pinned-messages) screen |
+| **Save Message** | All conversations | Only the acting user | Action-sheet option, bubble indicator, [Saved Messages](/ui-kit/android/saved-messages) screen |
+| **Pin Conversation** | Conversation list | Only the acting user | Long-press option + pin indicator in [Conversations](/ui-kit/android/conversations) |
+
+The options, confirmation dialogs, toasts and indicators are built into the UI Kit components. The only integration work is wiring the two full-screen views into your navigation.
+
+## Prerequisites
+
+- A working message view — see [Getting Started](/ui-kit/android/getting-started).
+- The features enabled for your app. Check at runtime with the flags on `CometChatUIKit`:
+
+```kotlin lines
+CometChatUIKit.isPinMessageEnabled()
+CometChatUIKit.isSaveMessageEnabled()
+CometChatUIKit.isPinConversationEnabled()
+```
+
+## Pin & Save in the Message List
+
+With the features enabled, [CometChatMessageList](/ui-kit/android/message-list) automatically adds **Pin message / Unpin message** and **Save message / Unsave message** to the long-press action sheet for text and media messages. The labels toggle with the message's current state.
+
+- **Pin** is role-gated in groups: the UI Kit shows the Pin/Unpin option only to participants with the **Admin** or **Moderator** scope, or the group **owner**. Everyone sees pinned indicators. In one-on-one chats both participants can pin. Note this gate is applied by the UI Kit — if you build custom pin UI directly on the SDK, apply your own role check.
+- **Save** has no role gating — every user can save any message.
+- **Pin** and **Save** apply immediately and show a toast (*Message pinned*, *Message saved*, …); **Unpin** and **Unsave** ask for confirmation first. If a pin or save limit is exceeded, the limit toast is generated from the server's response automatically.
+- Pinned and saved messages show **indicators in the bubble footer** (a filled pin / bookmark before the timestamp), updating live for all bubble types.
+
+## Step 1: Open Pinned Messages from the Chat Header
+
+[CometChatMessageHeader](/ui-kit/android/message-header) has a built-in **Pinned messages** menu item — enable it and handle the tap:
+
+
+
+```kotlin MessagesActivity.kt lines
+messageHeader.setShowPinnedMessagesOption(true)
+
+messageHeader.setOnPinnedMessagesClickListener {
+ val intent = Intent(this, PinnedMessagesActivity::class.java)
+ user?.let { u -> intent.putExtra("uid", u.uid) }
+ group?.let { g -> intent.putExtra("guid", g.guid) }
+ pinnedMessagesLauncher.launch(intent)
+}
+```
+
+
+
+Host [CometChatPinnedMessages](/ui-kit/android/pinned-messages) in that activity (or Compose destination), scoped with the same user/group as the chat.
+
+### Jump Back to a Pinned Message
+
+Return the tapped message's ID to the chat screen and scroll to it:
+
+
+
+```kotlin PinnedMessagesActivity.kt lines
+pinnedMessages.setOnMessageClickListener { message ->
+ setResult(RESULT_OK, Intent().putExtra("goToMessageId", message.id)) // message.id is a Long
+ finish()
+}
+```
+
+```kotlin MessagesActivity.kt lines
+private val pinnedMessagesLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val messageId = result.data?.getLongExtra("goToMessageId", 0L) ?: 0L
+ if (result.resultCode == RESULT_OK && messageId != 0L) {
+ messageList.gotoMessage(messageId)
+ }
+ }
+```
+
+
+```kotlin lines
+CometChatPinnedMessages(
+ user = user,
+ onMessageClick = { message ->
+ navController.navigate(MessagesRoute(goToMessageId = message.id)) {
+ popUpTo { inclusive = true }
+ }
+ }
+)
+```
+
+
+
+## Step 2: Open Saved Messages from Your App Chrome
+
+Saved messages are **user-level**, so the entry point belongs in app chrome — a profile/user menu on the conversations screen, a settings row, or a navigation tab — not inside a single chat:
+
+
+
+```kotlin ChatsFragment.kt lines
+// e.g. a "Saved messages" row in the user menu of your conversations screen
+savedMessagesMenuItem.isVisible = CometChatUIKit.isSaveMessageEnabled()
+savedMessagesMenuItem.setOnClickListener {
+ startActivity(Intent(requireContext(), SavedMessagesActivity::class.java))
+}
+```
+
+
+
+Host [CometChatSavedMessages](/ui-kit/android/saved-messages) there. Because rows span conversations, opening a tapped message means resolving its source conversation first:
+
+
+
+```kotlin SavedMessagesActivity.kt lines
+savedMessages.setOnMessageClickListener { message ->
+ val me = CometChat.getLoggedInUser()?.uid
+ val intent = Intent(this, MessagesActivity::class.java)
+ if (message.receiverType == CometChatConstants.RECEIVER_TYPE_GROUP) {
+ intent.putExtra("guid", (message.receiver as Group).guid)
+ } else {
+ val peer = if (message.sender.uid == me) message.receiver as User else message.sender
+ intent.putExtra("uid", peer.uid)
+ }
+ intent.putExtra("goToMessageId", message.id)
+ startActivity(intent)
+ finish()
+}
+```
+
+
+
+## Pin Conversations
+
+With the feature enabled, [CometChatConversations](/ui-kit/android/conversations) adds **Pin conversation / Unpin conversation** to the long-press menu, shows a pin indicator on pinned rows, and keeps pinned conversations at the top of the list — including when new messages arrive. No wiring is required; to hide the option:
+
+
+
+```kotlin lines
+conversations.setPinConversationOptionVisibility(View.GONE)
+```
+
+
+
+## Live Updates
+
+On the acting user's device, all surfaces stay in sync through the UI Kit event bus — pinning from the action sheet updates the bubble indicator and the Pinned Messages screen without a refetch. Delivery of pin/save events to other participants and to the user's other devices activates once server-side real-time delivery for these features is rolled out; until then, other clients pick the change up on their next fetch. If you build custom UI, observe the `MessagePinned` / `MessageUnpinned` / `MessageSaved` / `MessageUnsaved` events; see [Events](/ui-kit/android/events).
+
+## Summary / Feature Matrix
+
+| Capability | Built-in | Your wiring |
+| --- | --- | --- |
+| Action-sheet options, confirm dialogs, toasts | ✅ | — |
+| Bubble footer indicators | ✅ | — |
+| Pinned/Saved screens (list, unpin/unsave, empty states, live upkeep) | ✅ | Host + navigate |
+| Chat-header "Pinned messages" entry | ✅ (opt-in) | `setShowPinnedMessagesOption(true)` + click listener |
+| Saved messages entry point | — | An item in your app chrome |
+| Jump-to-message | — | `gotoMessage` / navigation |
+| Conversation pinning (option, indicator, ordering) | ✅ | — |
+
+## Next Steps & Further Reading
+
+- [Pinned Messages](/ui-kit/android/pinned-messages) · [Saved Messages](/ui-kit/android/saved-messages) — component references.
+- [Pin A Message](/sdk/android/v5/pin-message) · [Save A Message](/sdk/android/v5/save-message) · [Pin A Conversation](/sdk/android/v5/pin-conversation) — the SDK APIs underneath.
diff --git a/ui-kit/android/guide-thread-subscription.mdx b/ui-kit/android/guide-thread-subscription.mdx
new file mode 100644
index 000000000..eac1658d6
--- /dev/null
+++ b/ui-kit/android/guide-thread-subscription.mdx
@@ -0,0 +1,157 @@
+---
+title: "Thread Subscription"
+sidebarTitle: "Thread Subscription"
+description: "Let users subscribe to or unsubscribe from message threads so notifications only reach the people who care."
+---
+
+## Overview
+
+Thread subscription gives users Slack-style control over thread noise: they can **subscribe** to a thread to be notified about its replies, or **unsubscribe** from one to mute it. Users are automatically subscribed when they start a thread, reply in one, or are @-mentioned in one — subscribing explicitly is how they opt in to a conversation they haven't participated in yet.
+
+The UI Kit ships two surfaces for the same toggle, kept in sync automatically:
+
+1. A **Subscribe to thread / Unsubscribe from thread** option in the message action sheet.
+2. A **subscription bell** on the thread view.
+
+## Prerequisites
+
+- Threaded messages working in your app — see [Threaded Messages](/ui-kit/android/guide-threaded-messages).
+- CometChat UI Kit for Android with Chat SDK v5 or later.
+
+## Enable the Feature
+
+Thread subscription is **off by default** and is enabled per app via `UIKitSettings` at init time. When the gate is off, neither surface renders and no subscription request is ever made.
+
+
+
+```kotlin lines
+val uiKitSettings = UIKitSettings.UIKitSettingsBuilder()
+ .setAppId(APP_ID)
+ .setRegion(REGION)
+ .setAuthKey(AUTH_KEY)
+ .setEnableThreadSubscription(true) // opt in — default is false
+ .subscribePresenceForAllUsers()
+ .build()
+
+CometChatUIKit.init(this, uiKitSettings, object : CometChat.CallbackListener() {
+ override fun onSuccess(successString: String?) { }
+ override fun onError(e: CometChatException?) { }
+})
+```
+
+
+
+Anywhere you build your own UI around the feature, check the gate with:
+
+```kotlin lines
+if (CometChatUIKit.isThreadSubscriptionEnabled()) {
+ // render your subscription control / entry point
+}
+```
+
+## Surface 1: The Message Action Sheet Option
+
+With the gate on, [CometChatMessageList](/ui-kit/android/message-list) automatically adds a **Subscribe to thread** / **Unsubscribe from thread** option to the long-press action sheet. The label reflects the current state, and the option appears on regular messages of every type (agent messages and moderation-blocked messages are excluded) — on a thread reply it targets the thread's root message, so subscribing from anywhere in the thread works.
+
+To hide the option while keeping the rest of the feature:
+
+
+
+```kotlin lines
+messageList.setThreadSubscriptionOptionVisibility(View.GONE)
+```
+
+
+
+## Surface 2: The Thread Header Bell
+
+[CometChatThreadHeader](/ui-kit/android/threaded-messages-header) renders a subscription bell as a trailing control on the reply-count bar. It flips optimistically on tap and reverts with a toast if the request fails.
+
+
+
+```kotlin lines
+// Hide the bell (e.g. because you host your own — see below)
+threadHeader.setThreadSubscriptionVisibility(View.GONE)
+
+// Observe state changes (isSubscribed = the new state)
+threadHeader.setOnThreadSubscriptionChange { isSubscribed ->
+ Log.d(TAG, "Thread subscribed: $isSubscribed")
+}
+```
+
+The visibility can also be set in XML with the `app:cometchatThreadSubscriptionVisibility` attribute.
+
+
+
+```kotlin lines
+CometChatThreadHeader(
+ parentMessage = parentMessage,
+ hideThreadSubscription = false, // hide the built-in bell when true
+ isSubscribed = null, // null = derive from the SDK's state store
+ onSubscriptionToggle = { isSubscribed ->
+ Log.d(TAG, "Thread subscribed: $isSubscribed")
+ },
+ threadSubscriptionView = null // or your own composable replacing the bell
+)
+```
+
+
+
+### Hosting the Bell in Your Own Top Bar
+
+Many apps (matching the CometChat sample apps and Figma) place the subscription bell in the thread screen's **top title bar** rather than the reply-count row. In Compose, the bell is available as a standalone public composable — hide the header's built-in one and host `ThreadSubscriptionBell` wherever you like:
+
+
+
+```kotlin lines
+TopAppBar(
+ title = { Text(stringResource(R.string.thread)) },
+ actions = {
+ if (CometChatUIKit.isThreadSubscriptionEnabled()) {
+ ThreadSubscriptionBell(parentMessage = parentMessage)
+ }
+ }
+)
+
+CometChatThreadHeader(
+ parentMessage = parentMessage,
+ hideThreadSubscription = true // the bell lives in the top bar instead
+)
+```
+
+
+```kotlin lines
+// Hide the kit header's bell and drive your own ImageView in the activity's title bar:
+threadHeader.setThreadSubscriptionVisibility(View.GONE)
+
+// On tap: flip your icon optimistically, then call the SDK
+CometChat.subscribeToThread(parentMessage.id, object : CometChat.CallbackListener() {
+ override fun onSuccess(response: String?) { }
+ override fun onError(e: CometChatException?) {
+ // revert the icon and show a toast
+ }
+})
+```
+
+
+
+## Behavior
+
+- **Optimistic with revert** — both surfaces flip instantly on tap, keep one request in flight per thread, and revert with a toast if the server rejects the change. An offline tap fails visibly and reverts; nothing is queued.
+- **Auto-subscribe on reply** — sending a reply in a thread subscribes the user, and every surface flips to the subscribed state automatically.
+- **Unsubscribing is not sticky** — replying again, or being @-mentioned, re-subscribes the user.
+- **Unknown state renders as unsubscribed** — a message whose subscription state hasn't been learned yet (for example, one that just arrived in real time) shows the enabled subscribe control, never a spinner.
+
+## Cross-Surface Sync
+
+Both surfaces observe the UI Kit event bus, so toggling in one place updates the other without a refetch. If you build your own subscription control, emit and collect `CometChatThreadEvent` through `CometChatEvents.threadEvents` — see [Events](/ui-kit/android/events).
+
+## Notifications
+
+Whether a subscribed thread actually produces a push notification is governed by the user's notification preferences: the replies preference supports notifying only for **threads the user is subscribed to** (`SUBSCRIBE_TO_SUBSCRIBED_THREADS`). See [Thread Subscription (SDK)](/sdk/android/v5/thread-subscription#notification-preferences).
+
+## Next Steps & Further Reading
+
+- [Thread Subscription (SDK)](/sdk/android/v5/thread-subscription) — the underlying APIs, including fetching the threads a user participates in to build a thread inbox.
+- [Threaded Messages Header](/ui-kit/android/threaded-messages-header) — the full component reference.
+- [Message List](/ui-kit/android/message-list) — action-sheet options.
diff --git a/ui-kit/android/guide-threaded-messages.mdx b/ui-kit/android/guide-threaded-messages.mdx
index caeeed6f7..900ed3bd7 100644
--- a/ui-kit/android/guide-threaded-messages.mdx
+++ b/ui-kit/android/guide-threaded-messages.mdx
@@ -287,6 +287,9 @@ if (user.isBlockedByMe) {
## Next Steps & Further Reading
+
+ Let users subscribe to or unsubscribe from a thread to control whether its replies notify them.
+
Explore this feature in the CometChat SampleApp:
[GitHub → SampleApp](https://github.com/cometchat/cometchat-uikit-android/tree/v6/sample-app-kotlin)
diff --git a/ui-kit/android/message-composer.mdx b/ui-kit/android/message-composer.mdx
index 3ebcaa479..54fe8a119 100644
--- a/ui-kit/android/message-composer.mdx
+++ b/ui-kit/android/message-composer.mdx
@@ -376,6 +376,77 @@ CometChatMessageComposer(
+### Rich-Text Toolbar Trailing Buttons
+
+Append your own buttons at the trailing end of the rich-text formatting toolbar — for snippet inserters, template pickers, AI actions, or custom styling. The UI Kit renders its own divider between the built-in formatting buttons and your content; your buttons join the toolbar's horizontal scroll and follow RTL automatically.
+
+Your button receives a **`ComposerInputController`** — a live handle to read and mutate the composer input:
+
+| Member | Description |
+| --- | --- |
+| `text: String` | Current plain text of the input. |
+| `selection: IntRange` | Current selection; `start == end` means a collapsed caret. |
+| `isCursorCollapsed: Boolean` | `true` when there is no selected range. |
+| `insertAtCursor(textToInsert)` | Insert at the caret (replacing any active selection); the caret moves to the end of the insert. |
+| `replaceSelection(replacement)` | Replace the selection; degrades to insert when the caret is collapsed. |
+| `toggleFormat(format)` | Toggle one of the built-in `RichTextFormat` values over the selection. |
+| `mentionRanges(): List` | Ranges occupied by mentions, so custom logic can skip them. |
+
+
+
+
+```kotlin lines
+messageComposer.setRichTextToolbarTrailingViewListener(
+ object : RichTextToolbarTrailingViewListener {
+ override fun createView(
+ context: Context,
+ user: User?,
+ group: Group?,
+ input: ComposerInputController
+ ): View {
+ return ImageButton(context).apply {
+ setImageResource(R.drawable.ic_snippet)
+ setOnClickListener {
+ input.insertAtCursor("Thanks for reaching out! ")
+ }
+ }
+ }
+ }
+)
+```
+
+
+
+
+```kotlin lines
+CometChatMessageComposer(
+ user = user,
+ trailingToolbarContent = { input ->
+ IconButton(onClick = {
+ input.insertAtCursor("Thanks for reaching out! ")
+ }) {
+ Icon(painterResource(R.drawable.ic_snippet), contentDescription = "Snippet")
+ }
+ IconButton(onClick = {
+ input.toggleFormat(RichTextFormat.BOLD)
+ }) {
+ Icon(painterResource(R.drawable.ic_bold), contentDescription = "Bold")
+ }
+ }
+)
+```
+
+The slot is a `RowScope` lambda, so you can emit multiple buttons.
+
+
+
+
+
+
+The trailing section lives **inside** the rich-text toolbar — it is not rendered when the toolbar is hidden or the rich-text editor is disabled. The `ComposerInputController` is live only while the composer is mounted; don't retain it beyond your button's lifecycle.
+
+
+
### Attachment Options
Replace the default attachment options.
diff --git a/ui-kit/android/message-header.mdx b/ui-kit/android/message-header.mdx
index 8394ca212..744dc0b76 100644
--- a/ui-kit/android/message-header.mdx
+++ b/ui-kit/android/message-header.mdx
@@ -121,6 +121,38 @@ CometChatMessageHeader(
+#### `onPinnedMessagesClick` (XML Views)
+
+The header ships a built-in **Pinned messages** menu item, hidden by default. Enable it with `setShowPinnedMessagesOption(true)` — it renders only when the Pin Message feature is enabled for the app — and handle the tap to open your [Pinned Messages](/ui-kit/android/pinned-messages) screen.
+
+
+
+
+```kotlin lines
+messageHeader.setShowPinnedMessagesOption(true)
+
+messageHeader.setOnPinnedMessagesClickListener {
+ // open your screen hosting CometChatPinnedMessages
+}
+```
+
+
+
+
+```kotlin lines
+// The Compose header has no built-in menu — add a "Pinned messages" item
+// to your own top bar / overflow menu, gated on the feature flag:
+if (CometChatUIKit.isPinMessageEnabled()) {
+ DropdownMenuItem(
+ text = { Text("Pinned messages") },
+ onClick = { /* navigate to CometChatPinnedMessages */ }
+ )
+}
+```
+
+
+
+
#### `onError`
Fires on internal errors (network failure, auth issue, SDK exception).
@@ -169,6 +201,8 @@ The component listens to these SDK events internally. No manual setup needed.
| `setGroup(group)` | `group = group` | Display a group's header details |
| `setBackButtonVisibility(View.VISIBLE)` | `hideBackButton = false` | Toggle back button |
| `setOnBackPress { }` | `onBackPress = { }` | Back button callback |
+| `setShowPinnedMessagesOption(true)` | — | Show the built-in "Pinned messages" menu item (Views only; requires the Pin Message feature) |
+| `setOnPinnedMessagesClickListener { }` | — | Callback for the "Pinned messages" menu item |
---
diff --git a/ui-kit/android/message-list.mdx b/ui-kit/android/message-list.mdx
index bf9a7bb46..57ed10af2 100644
--- a/ui-kit/android/message-list.mdx
+++ b/ui-kit/android/message-list.mdx
@@ -850,6 +850,17 @@ Available visibility methods (Kotlin XML):
| `setTranslateMessageOptionVisibility()` | `VISIBLE` | Translate message |
| `setShareMessageOptionVisibility()` | `VISIBLE` | Share message |
| `setMarkAsUnreadOptionVisibility()` | `GONE` | Mark as unread |
+| `setThreadSubscriptionOptionVisibility()` | `VISIBLE`* | Subscribe / Unsubscribe thread option (*renders only when the thread-subscription feature gate is on) |
+
+### Feature Options (Pin, Save, Thread Subscription)
+
+Three groups of options appear automatically when their feature is enabled for the app — no wiring needed:
+
+- **Pin message / Unpin message** — shown on text and media messages when `CometChatUIKit.isPinMessageEnabled()`. In groups, the option is shown only to participants with the Admin or Moderator scope, or the group owner (a client-side gate). Pinning applies immediately with a toast; unpinning asks for confirmation first. Pinned messages get a pin indicator in the bubble footer.
+- **Save message / Unsave message** — shown on text and media messages when `CometChatUIKit.isSaveMessageEnabled()`, for every user. Saving applies immediately with a toast; unsaving asks for confirmation first. Saved messages get a bookmark indicator in the bubble footer.
+- **Subscribe to thread / Unsubscribe from thread** — shown on regular messages (not agent or moderation-blocked ones) when thread subscription is enabled via `UIKitSettings.setEnableThreadSubscription(true)`. On a thread reply the action targets the thread's root message. Hide it with `setThreadSubscriptionOptionVisibility(View.GONE)`.
+
+The labels toggle with the message's current state, and if a pin/save limit is exceeded the limit toast is generated from the server response automatically. See the [Pin & Save Messages](/ui-kit/android/guide-pin-and-save-messages) and [Thread Subscription](/ui-kit/android/guide-thread-subscription) guides.
### Replacing All Options (`setOptions`)
diff --git a/ui-kit/android/methods.mdx b/ui-kit/android/methods.mdx
index 88c6570aa..963704528 100644
--- a/ui-kit/android/methods.mdx
+++ b/ui-kit/android/methods.mdx
@@ -74,6 +74,7 @@ The `UIKitSettings` is an important parameter of the `init()` function. It serve
| **setAIFeatures** | `List` | Sets the AI Features that need to be added in UI Kit |
| **setExtensions** | `List` | Sets the list of extension that need to be added in UI Kit |
| **dateTimeFormatterCallback** | `DateTimeFormatterCallback` | Interface containing callback methods to format different types of timestamps. |
+| **setEnableThreadSubscription** | `Boolean` | Opt in to the thread subscription feature. Default `false` — no subscription controls render without it. See [Thread Subscription](/ui-kit/android/guide-thread-subscription) |
**Usage:**
@@ -417,6 +418,25 @@ CometChatUIKit.sendCustomMessage(customMessage, object : CometChat.CallbackListe
---
+### Feature Flags
+
+Synchronous, UI-safe checks for whether a feature is available. Use them to gate custom entry points; the built-in components already check them internally.
+
+| Method | Description |
+| --- | --- |
+| `CometChatUIKit.isPinMessageEnabled()` | Whether the Pin Message feature is enabled for the app. |
+| `CometChatUIKit.isSaveMessageEnabled()` | Whether the Save Message feature is enabled for the app. |
+| `CometChatUIKit.isPinConversationEnabled()` | Whether the Pin Conversation feature is enabled for the app. |
+| `CometChatUIKit.isThreadSubscriptionEnabled()` | Whether thread subscription was opted into via `UIKitSettings.setEnableThreadSubscription(true)`. |
+
+```kotlin
+if (CometChatUIKit.isPinMessageEnabled()) {
+ // show your "Pinned messages" entry point
+}
+```
+
+---
+
## Next steps
diff --git a/ui-kit/android/pinned-messages.mdx b/ui-kit/android/pinned-messages.mdx
new file mode 100644
index 000000000..272d7bce7
--- /dev/null
+++ b/ui-kit/android/pinned-messages.mdx
@@ -0,0 +1,149 @@
+---
+title: "Pinned Messages"
+description: "Full-screen list of all messages pinned in a conversation, with jump-to-message and unpin actions."
+---
+
+
+```json
+{
+ "component": "CometChatPinnedMessages",
+ "package": "com.cometchat.uikit.kotlin.presentation.pinnedmessages (XML Views) / com.cometchat.uikit.compose.presentation.pinnedmessages.ui (Compose)",
+ "xmlElement": "",
+ "description": "Full-screen list of all messages pinned in a conversation, rendered as real message bubbles, with jump-to-message and long-press row actions (unpin, copy, info, delete).",
+ "primaryOutput": {
+ "messageClicked": {
+ "method": "setOnMessageClickListener",
+ "type": "(BaseMessage) -> Unit"
+ }
+ },
+ "methods": {
+ "data": {
+ "setUser": { "type": "User", "note": "Scope to a one-on-one conversation. Set exactly one of setUser/setGroup." },
+ "setGroup": { "type": "Group", "note": "Scope to a group conversation." }
+ },
+ "callbacks": {
+ "setOnMessageClickListener": "(BaseMessage) -> Unit — row tapped; navigate to the message in its conversation",
+ "setOnBackClickListener": "() -> Unit — toolbar back pressed"
+ }
+ },
+ "composeParams": {
+ "user": "User? — scope to a one-on-one conversation",
+ "group": "Group? — scope to a group conversation",
+ "onMessageClick": "(BaseMessage) -> Unit",
+ "onBackClick": "() -> Unit"
+ },
+ "events": ["CometChatMessageEvent.MessagePinned", "CometChatMessageEvent.MessageUnpinned"],
+ "featureFlag": "CometChatUIKit.isPinMessageEnabled()"
+}
+```
+
+
+
+## Where It Fits
+
+`CometChatPinnedMessages` is a full-screen component that lists every message pinned in a single conversation, most recently pinned first. Each row renders the actual message bubble — with the sender's avatar, name and date — so pinned media, files and text all look exactly as they do in the chat. Open it from your conversation screen (the [Message Header](/ui-kit/android/message-header) provides a built-in "Pinned messages" menu item for this), and wire `setOnMessageClickListener` to navigate back to the message in context.
+
+Messages are pinned and unpinned from the [Message List](/ui-kit/android/message-list) action sheet; this screen is the read view, plus a long-press menu on each row (Message info, Copy, Unpin, Subscribe/Unsubscribe to thread, Delete).
+
+
+
+Pinned messages require the **Pin Message** feature to be enabled for your app. Gate your entry point with `CometChatUIKit.isPinMessageEnabled()`.
+
+
+
+## Quick Start
+
+
+
+
+Add the component to your layout XML:
+
+```xml activity_pinned_messages.xml lines
+
+
+
+
+
+
+```
+
+Scope it to the conversation and wire the callbacks:
+
+```kotlin PinnedMessagesActivity.kt lines
+class PinnedMessagesActivity : AppCompatActivity() {
+
+ private lateinit var pinnedMessages: CometChatPinnedMessages
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_pinned_messages)
+
+ pinnedMessages = findViewById(R.id.pinned_messages)
+
+ // Scope to the conversation — set exactly one of user / group
+ user?.let { pinnedMessages.setUser(it) }
+ group?.let { pinnedMessages.setGroup(it) }
+
+ pinnedMessages.setOnMessageClickListener { message ->
+ // Navigate to the message in its conversation,
+ // e.g. finish with a result and call messageList.gotoMessage(message.id)
+ }
+
+ pinnedMessages.setOnBackClickListener { finish() }
+ }
+}
+```
+
+
+
+
+```kotlin lines
+CometChatPinnedMessages(
+ user = user, // or group = group — set exactly one
+ onMessageClick = { message ->
+ // Navigate to the message in its conversation
+ },
+ onBackClick = { navController.popBackStack() }
+)
+```
+
+
+
+
+## Actions and Events
+
+### Callback Methods
+
+| Method (Views) / Param (Compose) | Description |
+| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
+| `setOnMessageClickListener` / `onMessageClick` | Fired when a row is tapped. Receives the `BaseMessage`; navigate to it in its conversation. |
+| `setOnBackClickListener` / `onBackClick` | Fired when the toolbar back button is pressed. |
+
+### SDK Events (Real-Time, Automatic)
+
+The list keeps itself up to date — when the logged-in user pins or unpins a message anywhere in the app, the row is added or removed without a refetch, via the `MessagePinned` / `MessageUnpinned` events on the UI Kit event bus; see [Events](/ui-kit/android/events). Pins made by other participants appear when the list is next opened or refetched (server-side real-time delivery for pin events is pending rollout).
+
+## Functionality
+
+- **Real bubbles** — each row hosts the message's actual bubble (text, image, video, audio, file), left-aligned with a `name • date` header and the sender's avatar. Your own messages render with the outgoing (primary-color) bubble style and the name **You**.
+- **Long-press menu** — long-press a row for message actions: **Message info**, **Copy**, **Unpin**, **Subscribe/Unsubscribe to thread**, and **Delete**. Unpinning asks for confirmation before it is performed.
+- **Jump to message** — tapping a row emits the message through the click callback so you can open the conversation and scroll to it.
+- **Empty state** — a built-in empty state ("No pinned messages yet") is shown when the conversation has no pinned messages.
+- **Read-only** — the screen never marks messages as read and does not affect unread counts or receipts.
+
+## ViewModel
+
+The screen is backed by `CometChatPinnedMessagesViewModel` (in the shared core module), which fetches via `MessagesRequestBuilder().setPinned(true)` scoped to the set user or group, applies optimistic unpin with revert-on-error, and observes the event bus for live upkeep. In Compose you can inject your own instance through the `viewModel` parameter.
+
+## Next Steps
+
+- [Message List](/ui-kit/android/message-list) — where messages are pinned and unpinned, and where the bubble pin indicator appears.
+- [Message Header](/ui-kit/android/message-header) — the built-in "Pinned messages" menu entry point.
+- [Saved Messages](/ui-kit/android/saved-messages) — the private, cross-conversation counterpart.
+- [Pin A Message (SDK)](/sdk/android/v5/pin-message) — the underlying SDK APIs.
diff --git a/ui-kit/android/saved-messages.mdx b/ui-kit/android/saved-messages.mdx
new file mode 100644
index 000000000..58181f88c
--- /dev/null
+++ b/ui-kit/android/saved-messages.mdx
@@ -0,0 +1,139 @@
+---
+title: "Saved Messages"
+description: "Full-screen, private list of every message the logged-in user has saved, across all of their conversations."
+---
+
+
+```json
+{
+ "component": "CometChatSavedMessages",
+ "package": "com.cometchat.uikit.kotlin.presentation.savedmessages (XML Views) / com.cometchat.uikit.compose.presentation.savedmessages.ui (Compose)",
+ "xmlElement": "",
+ "description": "Full-screen, private list of every message the logged-in user has saved across all conversations, with conversation-style rows, jump-to-message and long-press unsave.",
+ "primaryOutput": {
+ "messageClicked": {
+ "method": "setOnMessageClickListener",
+ "type": "(BaseMessage) -> Unit"
+ }
+ },
+ "methods": {
+ "callbacks": {
+ "setOnMessageClickListener": "(BaseMessage) -> Unit — row tapped; open the source conversation at the message",
+ "setOnBackClickListener": "() -> Unit — toolbar back pressed"
+ }
+ },
+ "composeParams": {
+ "onMessageClick": "(BaseMessage) -> Unit",
+ "onBackClick": "() -> Unit"
+ },
+ "note": "User-level — no setUser/setGroup. Rows span all of the user's conversations.",
+ "events": ["CometChatMessageEvent.MessageSaved", "CometChatMessageEvent.MessageUnsaved"],
+ "featureFlag": "CometChatUIKit.isSaveMessageEnabled()"
+}
+```
+
+
+
+## Where It Fits
+
+`CometChatSavedMessages` is a full-screen component that lists every message the logged-in user has bookmarked, most recently saved first. Saved messages are **private to the user** and **span all of their conversations**, so this screen is user-level: open it from your app's chrome — a profile menu, the conversations screen's user menu, or a navigation tab — not from inside a single chat. There is no `setUser`/`setGroup`; the scope is always the logged-in user.
+
+Messages are saved and unsaved from the [Message List](/ui-kit/android/message-list) action sheet; this screen is the read view, plus a long-press **Unsave** action on each row.
+
+
+
+Saved messages require the **Save Message** feature to be enabled for your app. Gate your entry point with `CometChatUIKit.isSaveMessageEnabled()`.
+
+
+
+## Quick Start
+
+
+
+
+Add the component to your layout XML:
+
+```xml activity_saved_messages.xml lines
+
+
+
+
+
+
+```
+
+Wire the callbacks:
+
+```kotlin SavedMessagesActivity.kt lines
+class SavedMessagesActivity : AppCompatActivity() {
+
+ private lateinit var savedMessages: CometChatSavedMessages
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_saved_messages)
+
+ savedMessages = findViewById(R.id.saved_messages)
+
+ savedMessages.setOnMessageClickListener { message ->
+ // Open the source conversation at this message. Resolve the peer from
+ // the message: group -> message.receiver as Group; one-on-one -> the
+ // sender if it isn't the logged-in user, otherwise the receiver.
+ }
+
+ savedMessages.setOnBackClickListener { finish() }
+ }
+}
+```
+
+
+
+
+```kotlin lines
+CometChatSavedMessages(
+ onMessageClick = { message ->
+ // Open the source conversation at this message
+ },
+ onBackClick = { navController.popBackStack() }
+)
+```
+
+
+
+
+## Actions and Events
+
+### Callback Methods
+
+| Method (Views) / Param (Compose) | Description |
+| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
+| `setOnMessageClickListener` / `onMessageClick` | Fired when a row is tapped. Receives the `BaseMessage`; open its source conversation and scroll to the message. |
+| `setOnBackClickListener` / `onBackClick` | Fired when the toolbar back button is pressed. |
+
+### SDK Events (Real-Time, Automatic)
+
+The list keeps itself up to date on this device — saving or unsaving a message anywhere in the app adds or removes the row without a refetch, via the `MessageSaved` / `MessageUnsaved` events on the UI Kit event bus; see [Events](/ui-kit/android/events). Changes made on the user's other devices appear when the list is next opened or refetched (server-side real-time delivery for save events is pending rollout).
+
+## Functionality
+
+- **Conversation-style rows** — because saved messages come from many conversations, each row shows where the message lives: the peer's or group's avatar and name, a message preview with its type icon, and the sent date. This mirrors a conversation list row (without the unread badge).
+- **Unsave** — long-press a row to get the **Unsave** action; a toast confirms the result.
+- **Jump to message** — tapping a row emits the message through the click callback. Use the message's receiver type and receiver to resolve which conversation to open.
+- **Empty state** — a built-in empty state ("No saved messages yet") is shown when the user has not saved anything.
+- **Private and read-only** — nobody else can see a user's saved messages; the screen never marks messages as read and does not affect unread counts or receipts.
+
+## ViewModel
+
+The screen is backed by `CometChatSavedMessagesViewModel` (in the shared core module), which fetches via `MessagesRequestBuilder().setSaved(true)`, applies optimistic unsave with revert-on-error, and observes the event bus for live upkeep. In Compose you can inject your own instance through the `viewModel` parameter.
+
+## Next Steps
+
+- [Message List](/ui-kit/android/message-list) — where messages are saved and unsaved, and where the bubble saved indicator appears.
+- [Pinned Messages](/ui-kit/android/pinned-messages) — the conversation-wide counterpart.
+- [Save A Message (SDK)](/sdk/android/v5/save-message) — the underlying SDK APIs.
diff --git a/ui-kit/android/threaded-messages-header.mdx b/ui-kit/android/threaded-messages-header.mdx
index c9054a2ff..089b42d7c 100644
--- a/ui-kit/android/threaded-messages-header.mdx
+++ b/ui-kit/android/threaded-messages-header.mdx
@@ -93,7 +93,44 @@ Prerequisites: CometChat SDK initialized with `CometChatUIKit.init()`, a user lo
### Callback Methods
-`CometChatThreadHeader` is a display-only header. It does not expose component-specific callbacks like `setOnItemClick` or `setOnError`.
+`CometChatThreadHeader` is a display-only header. It does not expose component-specific callbacks like `setOnItemClick` or `setOnError`. The one interactive element is the **subscription bell** (below), which reports state changes through its own callback.
+
+#### Subscription Bell (`onThreadSubscriptionChange` / `onSubscriptionToggle`)
+
+When [thread subscription](/ui-kit/android/guide-thread-subscription) is enabled (`UIKitSettings.setEnableThreadSubscription(true)`), the header renders a subscription bell as a trailing control on the reply-count bar. It flips optimistically on tap, reverts with a toast on failure, and stays in sync with the message list's Subscribe/Unsubscribe option automatically.
+
+
+
+
+```kotlin lines
+// Observe state changes
+threadHeader.setOnThreadSubscriptionChange { isSubscribed ->
+ Log.d(TAG, "Thread subscribed: $isSubscribed")
+}
+
+// Hide the bell (e.g. to host your own control in the activity's title bar)
+threadHeader.setThreadSubscriptionVisibility(View.GONE)
+```
+
+Visibility can also be set in XML via `app:cometchatThreadSubscriptionVisibility`.
+
+
+
+
+```kotlin lines
+CometChatThreadHeader(
+ parentMessage = parentMessage,
+ hideThreadSubscription = false, // true to hide the built-in bell
+ isSubscribed = null, // null = derive from the SDK's state store
+ onSubscriptionToggle = { isSubscribed -> },
+ threadSubscriptionView = null // or your own composable replacing the bell
+)
+```
+
+The bell is also available standalone as the public `ThreadSubscriptionBell(parentMessage)` composable, so you can hide the header's and host it in your own top bar.
+
+
+
### SDK Events (Real-Time, Automatic)
@@ -118,6 +155,9 @@ The component listens to SDK events internally via its ViewModel. No manual setu
| `setAvatarVisibility(View.GONE)` | `hideAvatar = true` | Toggle avatar visibility |
| `setReceiptsVisibility(View.GONE)` | `hideReceipts = true` | Toggle read receipts |
| `setReplyCountVisibility(View.GONE)` | `hideReplyCount = true` | Toggle reply count text |
+| `setThreadSubscriptionVisibility(View.GONE)` | `hideThreadSubscription = true` | Toggle the subscription bell (renders only when thread subscription is enabled) |
+| `setOnThreadSubscriptionChange { }` | `onSubscriptionToggle = { }` | Subscription-state change callback |
+| — | `threadSubscriptionView = { }` | Replace the bell with a custom composable |
---