diff --git a/docs.json b/docs.json
index cb43da08b..ec28bfdad 100644
--- a/docs.json
+++ b/docs.json
@@ -1496,6 +1496,8 @@
"ui-kit/ios/message-composer",
"ui-kit/ios/compact-message-composer",
"ui-kit/ios/threaded-messages-header",
+ "ui-kit/ios/pinned-messages",
+ "ui-kit/ios/saved-messages",
"ui-kit/ios/incoming-call",
"ui-kit/ios/outgoing-call",
"ui-kit/ios/ongoing-call",
@@ -1526,6 +1528,8 @@
"pages": [
"ui-kit/ios/guide-overview",
"ui-kit/ios/guide-threaded-messages",
+ "ui-kit/ios/guide-thread-subscription",
+ "ui-kit/ios/guide-pin-save-message",
"ui-kit/ios/guide-block-unblock-user",
"ui-kit/ios/guide-new-chat",
"ui-kit/ios/guide-message-privately",
@@ -3846,6 +3850,7 @@
"sdk/ios/additional-message-filtering",
"sdk/ios/retrieve-conversations",
"sdk/ios/threaded-messages",
+ "sdk/ios/thread-subscription",
"sdk/ios/edit-message",
"sdk/ios/delete-message",
"sdk/ios/delete-conversation",
@@ -3854,7 +3859,8 @@
"sdk/ios/transient-messages",
"sdk/ios/delivery-read-receipts",
"sdk/ios/mentions",
- "sdk/ios/reactions"
+ "sdk/ios/reactions",
+ "sdk/ios/pin-save-message"
]
},
"sdk/ios/calling-overview",
diff --git a/sdk/ios/additional-message-filtering.mdx b/sdk/ios/additional-message-filtering.mdx
index d4e3f28ca..cf70e4452 100644
--- a/sdk/ios/additional-message-filtering.mdx
+++ b/sdk/ios/additional-message-filtering.mdx
@@ -146,13 +146,17 @@ Use `setTags()` with an array of tag names to fetch only messages with those tag
```swift
var limit = 30
-let tags = ["pinned"]
+let tags = ["important"]
let messagesRequest = MessagesRequest.MessageRequestBuilder()
.setLimit(50)
.setTags(tags)
.build()
```
+
+To fetch pinned or saved messages, filter with `set(pinned:)` or `set(saved:)` rather than tags — see [Pin & Save Messages](/sdk/ios/pin-save-message).
+
+
---
## Next Steps
diff --git a/sdk/ios/all-real-time-delegates-listeners.mdx b/sdk/ios/all-real-time-delegates-listeners.mdx
index 5ea3143d7..efbe9fd04 100644
--- a/sdk/ios/all-real-time-delegates-listeners.mdx
+++ b/sdk/ios/all-real-time-delegates-listeners.mdx
@@ -212,6 +212,10 @@ The `CometChatMessageDelegate` provides you with live events related to messages
| **onInteractionGoalCompleted(_ receipt: InteractionReceipt)**| This event is triggered when an interaction Goal is achieved. |
| **onMessageReactionAdded(reactionEvent: ReactionEvent)** | This event is triggered when a reaction is added to a message. |
| **onMessageReactionRemoved(reactionEvent: ReactionEvent)** | This event is triggered when a reaction is removed from a message. |
+| **onMessagePinned(message:** [`BaseMessage`](/sdk/reference/messages#basemessage)**)** | This event is triggered when a message is pinned. Broadcast to everyone in the conversation. See [Pin & Save Messages](/sdk/ios/pin-save-message). |
+| **onMessageUnpinned(message:** [`BaseMessage`](/sdk/reference/messages#basemessage)**)** | This event is triggered when a message is unpinned. Broadcast to everyone in the conversation. |
+| **onMessageSaved(message:** [`BaseMessage`](/sdk/reference/messages#basemessage)**)** | This event is triggered when a message is saved. Private — reaches only the saving user's other devices. |
+| **onMessageUnsaved(message:** [`BaseMessage`](/sdk/reference/messages#basemessage)**)** | This event is triggered when a message is unsaved. Private — reaches only the saving user's other devices. |
| **onMessageModerated(_ message:** [`BaseMessage`](/sdk/reference/messages#basemessage)**)** | This event is triggered when a message is moderated. |
| **onAIAssistantMessageReceived(_ message: AIAssistantMessage)** | This event is triggered when an AI Assistant message is received. |
| **onAIToolResultMessageReceived(_ message: AIToolResultMessage)** | This event is triggered when an AI Tool result message is received. |
diff --git a/sdk/ios/pin-save-message.mdx b/sdk/ios/pin-save-message.mdx
new file mode 100644
index 000000000..ef3e44d76
--- /dev/null
+++ b/sdk/ios/pin-save-message.mdx
@@ -0,0 +1,308 @@
+---
+title: "Pin & Save Messages"
+sidebarTitle: "Pin & Save Messages"
+description: "Pin messages for everyone in a conversation and save messages privately using the CometChat iOS SDK, including message fields, fetching, and listener events."
+---
+
+
+
+```swift
+// Pin a message for everyone in the conversation
+CometChat.pinMessage(messageId: 148) { msg in } onError: { err in }
+CometChat.unpinMessage(messageId: 148) { msg in } onError: { err in }
+
+// Save a message privately for the logged-in user
+CometChat.saveMessage(messageId: 148) { msg in } onError: { err in }
+CometChat.unsaveMessage(messageId: 148) { msg in } onError: { err in }
+
+// Presence of the timestamp IS the boolean
+let isPinned = message.pinnedAt != 0
+let isSaved = message.savedAt != 0
+
+// Fetch pinned messages in a conversation
+MessagesRequest.MessageRequestBuilder().set(guid: "guid").set(pinned: true).set(limit: 100).build()
+
+// Fetch the user's saved messages across all conversations
+MessagesRequest.MessageRequestBuilder().set(saved: true).set(limit: 100).build()
+
+// Listen for events (CometChatMessageDelegate)
+func onMessagePinned(message: BaseMessage) { }
+func onMessageUnpinned(message: BaseMessage) { }
+func onMessageSaved(message: BaseMessage) { }
+func onMessageUnsaved(message: BaseMessage) { }
+```
+
+
+Pin and save let users mark messages for later. The two features look similar but differ in who sees the result:
+
+| | Pin | Save |
+|---|---|---|
+| Visibility | Everyone in the conversation | Private to the user |
+| Scope | One conversation | All conversations |
+| Permission | Group owner, admin or moderator | Anyone |
+| Limit | 100 per conversation | 100 per user |
+
+---
+
+## Pin a Message
+
+Pinning is conversation-wide: every participant sees the message as pinned.
+
+
+
+```swift
+CometChat.pinMessage(messageId: 148) { message in
+ print("Pinned at: \(message.pinnedAt) by \(message.pinnedBy)")
+} onError: { error in
+ print("Error: \(error.errorDescription)")
+}
+```
+
+
+
+
+Pinning is permission-gated. In a group, only the owner, admins and moderators can pin or unpin; everyone can view the pinned list. In one-to-one conversations both participants can pin.
+
+
+---
+
+## Unpin a Message
+
+
+
+```swift
+CometChat.unpinMessage(messageId: 148) { message in
+ // pinnedAt is cleared back to 0
+ print("Unpinned: \(message.pinnedAt == 0)")
+} onError: { error in
+ print("Error: \(error.errorDescription)")
+}
+```
+
+
+
+---
+
+## Save a Message
+
+Saving is private to the logged-in user and syncs across that user's own devices. It carries no permission checks.
+
+
+
+```swift
+CometChat.saveMessage(messageId: 148) { message in
+ print("Saved at: \(message.savedAt)")
+} onError: { error in
+ print("Error: \(error.errorDescription)")
+}
+```
+
+
+
+---
+
+## Unsave a Message
+
+
+
+```swift
+CometChat.unsaveMessage(messageId: 148) { message in
+ print("Unsaved: \(message.savedAt == 0)")
+} onError: { error in
+ print("Error: \(error.errorDescription)")
+}
+```
+
+
+
+All four methods return the updated [`BaseMessage`](/sdk/reference/messages#basemessage).
+
+---
+
+## Message Fields
+
+Three fields on `BaseMessage` carry the state:
+
+| Field | Type | Scope | Description |
+|-------|------|-------|-------------|
+| `pinnedAt` | `Double` | Global | When the message was pinned. `0` when not pinned. |
+| `pinnedBy` | `String` | Global | UID of the last user to pin it. `app_system` for a system pin. |
+| `savedAt` | `Double` | Per-viewer | When the logged-in user saved it. `0` when not saved. |
+
+
+**Presence of the timestamp is the boolean.** There is no separate `isPinned` flag — check `pinnedAt != 0` and `savedAt != 0`. Unpinning and unsaving clear the field back to `0` rather than leaving a stale value.
+
+`savedAt` is per-viewer: it is only ever populated for the user who saved the message, so you cannot use it to tell whether someone else saved it.
+
+
+
+
+```swift
+let isPinned = message.pinnedAt != 0
+let isSaved = message.savedAt != 0
+
+// A pin applied by an admin from the dashboard rather than a user
+let isSystemPin = message.pinnedBy == "app_system"
+```
+
+
+
+---
+
+## Fetch Pinned Messages
+
+Pinned messages are fetched with `MessagesRequest` filtered by `pinned`, scoped to a conversation with `uid` or `guid`.
+
+
+
+```swift
+let request = MessagesRequest.MessageRequestBuilder()
+ .set(guid: "cometchat-guid-1")
+ .set(pinned: true)
+ .set(limit: 100)
+ .build()
+
+request.fetchPrevious { messages in
+ print("Pinned: \(messages?.count ?? 0)")
+} onError: { error in
+ print("Error: \(error?.errorDescription)")
+}
+```
+
+
+```swift
+let request = MessagesRequest.MessageRequestBuilder()
+ .set(uid: "cometchat-uid-1")
+ .set(pinned: true)
+ .set(limit: 100)
+ .build()
+
+request.fetchPrevious { messages in
+ print("Pinned: \(messages?.count ?? 0)")
+} onError: { error in
+ print("Error: \(error?.errorDescription)")
+}
+```
+
+
+
+---
+
+## Fetch Saved Messages
+
+Saved messages are a per-user collection spanning every conversation, so the request takes **no** `uid` or `guid`.
+
+
+
+```swift
+let request = MessagesRequest.MessageRequestBuilder()
+ .set(saved: true)
+ .set(limit: 100)
+ .build()
+
+request.fetchPrevious { messages in
+ // Each message carries receiverUid / receiverType identifying its source conversation
+ print("Saved: \(messages?.count ?? 0)")
+} onError: { error in
+ print("Error: \(error?.errorDescription)")
+}
+```
+
+
+
+
+Both limits are capped at 100 server-side, so a single page fetches the whole list.
+
+
+---
+
+## Real-time Events
+
+Implement `CometChatMessageDelegate` to receive pin and save updates. All four callbacks are optional and each carries the full updated message.
+
+
+
+```swift
+extension ViewController: CometChatMessageDelegate {
+
+ func onMessagePinned(message: BaseMessage) {
+ // Broadcast to everyone in the conversation
+ print("Pinned by \(message.pinnedBy)")
+ }
+
+ func onMessageUnpinned(message: BaseMessage) {
+ print("Unpinned: \(message.id)")
+ }
+
+ func onMessageSaved(message: BaseMessage) {
+ // Private — only reaches the saving user's other devices
+ print("Saved: \(message.id)")
+ }
+
+ func onMessageUnsaved(message: BaseMessage) {
+ print("Unsaved: \(message.id)")
+ }
+}
+```
+
+
+
+Register the delegate as you would for any message event — see [Real-time Delegates and Listeners](/sdk/ios/all-real-time-delegates-listeners).
+
+Pin events are broadcast to every participant. Save events are private and reach only the saving user's other devices.
+
+---
+
+## Feature Availability
+
+Both features are gated per app and can be checked before showing any UI:
+
+
+
+```swift
+if CometChat.isPinMessageEnabled() {
+ // Show pin options
+}
+
+if CometChat.isSaveMessageEnabled() {
+ // Show save options
+}
+```
+
+
+
+---
+
+## Error Handling
+
+| Error code | Meaning |
+|------------|---------|
+| `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` | The conversation already has the maximum pinned messages. |
+| `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` | The user already has the maximum saved messages. |
+| `ERR_PERMISSION_DENIED` | The user's scope does not allow pinning here. |
+| `ERR_MESSAGE_NO_ACCESS` | The user cannot access this message. |
+| `ERR_MESSAGE_ACTION_NOT_ALLOWED` | The action is not allowed on this message. |
+| `ERR_FEATURE_NOT_ACCESSIBLE` | The feature is not enabled for this app. |
+
+For the two limit errors, read the actual cap from the exception's `errorParams` rather than hard-coding a number — the limit is server-owned and may change.
+
+---
+
+## Edge Cases
+
+| Scenario | Behavior |
+|----------|----------|
+| A pinned message is deleted | Automatically unpinned; no placeholder is left behind. |
+| A pinned message is edited | Keeps its pin. |
+| A saved message becomes inaccessible | Dropped from subsequent saved fetches. |
+| A thread reply | Can be pinned and saved. Pinned lists return the parent message for context. |
+| Someone else unpins your pin | Allowed — any user with permission can unpin. |
+
+---
+
+## Related
+
+- [Pinned Messages UI Component](/ui-kit/ios/pinned-messages)
+- [Saved Messages UI Component](/ui-kit/ios/saved-messages)
+- [Additional Message Filtering](/sdk/ios/additional-message-filtering)
+- [Real-time Delegates and Listeners](/sdk/ios/all-real-time-delegates-listeners)
diff --git a/sdk/ios/send-message.mdx b/sdk/ios/send-message.mdx
index 9df4db075..7cce7a243 100644
--- a/sdk/ios/send-message.mdx
+++ b/sdk/ios/send-message.mdx
@@ -122,10 +122,14 @@ NSDictionary * metadata = @{@"latitude":@"50.6192171633316",@"longitude":@"-72.6
### Add Tags
```swift
-let tags = ["pinned"]
+let tags = ["important"]
textMessage.tags = tags
```
+
+Tags are arbitrary labels of your own. To pin or save a message, use the dedicated APIs instead — see [Pin & Save Messages](/sdk/ios/pin-save-message).
+
+
### Quote a Message
```swift
diff --git a/sdk/ios/thread-subscription.mdx b/sdk/ios/thread-subscription.mdx
new file mode 100644
index 000000000..3083911b2
--- /dev/null
+++ b/sdk/ios/thread-subscription.mdx
@@ -0,0 +1,203 @@
+---
+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 iOS SDK.
+
+
+
+Thread subscription builds on [Threaded Messages](/sdk/ios/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.
+
+
+
+```swift
+let parentMessageId = 1
+
+CometChat.subscribeToThread(parentMessageId: parentMessageId) { response in
+ print("Subscribed to thread: \(response)")
+} onError: { error in
+ print("Failed to subscribe: \(error.errorDescription)")
+}
+```
+
+
+
+## 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.
+
+
+
+```swift
+let parentMessageId = 1
+
+CometChat.unsubscribeFromThread(parentMessageId: parentMessageId) { response in
+ print("Unsubscribed from thread: \(response)")
+} onError: { error in
+ print("Failed to unsubscribe: \(error.errorDescription)")
+}
+```
+
+
+
+
+
+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
+
+`threadSubscriptionState(forParentMessageId:)` 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.
+
+
+
+```swift
+switch CometChat.threadSubscriptionState(forParentMessageId: parentMessageId) {
+case .SUBSCRIBED: break // render "Unsubscribe from thread"
+case .NOT_SUBSCRIBED: break // render "Subscribe to thread"
+case .UNKNOWN: break // render "Subscribe to thread"
+@unknown default: break
+}
+```
+
+
+
+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.
+
+
+
+## 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 |
+| ----------------------------- | -------------------------------------------------------------------------------------------- |
+| `set(limit:)` | Page size. Thread rows are heavy — each carries a root message and a last reply. |
+| `set(uid:)` | Scope the list to threads in the one-on-one conversation with this user. Mutually exclusive with `set(guid:)`. |
+| `set(guid:)` | Scope the list to threads in this group. Mutually exclusive with `set(uid:)`. |
+| `set(participatedByMe:)` | Defaults to `true`. Only the threads the logged-in user participates in are returned. |
+
+
+
+```swift
+let threadsRequest = ThreadsRequest.ThreadsRequestBuilder()
+ .set(limit: 30)
+ .build()
+
+threadsRequest.fetchNext { threads in
+ for thread in threads {
+ print("Thread \(thread.parentMessageId) has \(thread.replyCount) replies")
+ }
+} onError: { error in
+ print("Threads fetch failed: \(error.errorDescription)")
+}
+```
+
+
+
+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.
+
+### The MessageThread Model
+
+Each row is a `MessageThread`:
+
+| Property | Description |
+| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `parentMessageId` | The thread's identity — the ID of its root message. |
+| `parentMessage` | The root message as a full `BaseMessage`. |
+| `replyCount` | Number of replies in the thread. |
+| `lastReply` | The most recent reply as a `BaseMessage`. `nil` for a thread with no replies yet — expected, not an error. |
+| `conversationId` | The ID of the conversation the thread belongs to. |
+| `receiverType` | `user` or `group`. |
+| `receiverUid` | The raw `UID`/`GUID` of the conversation. Resolve the display name and avatar yourself via `CometChat.getUser()` / `CometChat.getGroup()`. |
+| `subscriptionState` | Always `SUBSCRIBED` for rows in this list. |
+| `unreadReplyCount` | Reserved for future use — currently `nil` (unknown), which is not the same as `0`. |
+| `updatedAt` | An internal pagination cursor. **Do not sort your UI on it.** |
+
+
+
+To order rows in your UI, sort on `lastReply?.sentAt`, falling back to `parentMessage?.sentAt` for zero-reply threads — not on `updatedAt`.
+
+
+
+
+
+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
+
+Conform to `CometChatThreadDelegate` to keep your UI in sync as subscription state changes and replies arrive.
+
+
+
+```swift
+CometChat.threadDelegate = self
+
+extension ViewController: CometChatThreadDelegate {
+
+ func onThreadSubscriptionChanged(event: ThreadSubscriptionEvent) {
+ print("Thread \(event.parentMessageId) is now \(event.subscriptionState)")
+ }
+
+ func onThreadReplyReceived(event: ThreadReplyEvent) {
+ print("New reply in thread \(event.parentMessageId): \(event.reply.id)")
+ }
+}
+```
+
+
+
+Both callbacks are optional. The events carry:
+
+| Event | Properties |
+| ------------------------- | --------------------------------------------------------------------- |
+| `ThreadSubscriptionEvent` | `parentMessageId`, `subscriptionState`, `source` |
+| `ThreadReplyEvent` | `parentMessageId`, `reply`, `conversationId`, `source` |
+
+- `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.
+
+
+
+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 replies options.
+
+| 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/ios/threaded-messages.mdx b/sdk/ios/threaded-messages.mdx
index 2b609da8f..d49e601d8 100644
--- a/sdk/ios/threaded-messages.mdx
+++ b/sdk/ios/threaded-messages.mdx
@@ -177,6 +177,9 @@ messagesRequest.fetchPrevious(onSuccess: { (messages) in
## Next Steps
+
+ Subscribe to or unsubscribe from a thread, and fetch the threads a user participates in
+
Send text, media, and custom messages to users and groups
diff --git a/ui-kit/ios/components-overview.mdx b/ui-kit/ios/components-overview.mdx
index 06fa3c5b1..d76c21f36 100644
--- a/ui-kit/ios/components-overview.mdx
+++ b/ui-kit/ios/components-overview.mdx
@@ -96,6 +96,8 @@ UI elements with built-in business logic. They fetch data, handle actions, and e
| `CometChatMessageComposer` | Message input |
| `CometChatMessageHeader` | Chat header |
| `CometChatCallLogs` | Call history |
+| `CometChatPinnedMessages` | Pinned messages in one conversation |
+| `CometChatSavedMessages` | The logged-in user's saved messages, across all conversations |
diff --git a/ui-kit/ios/core-features.mdx b/ui-kit/ios/core-features.mdx
index ae494ff0d..615977623 100644
--- a/ui-kit/ios/core-features.mdx
+++ b/ui-kit/ios/core-features.mdx
@@ -16,6 +16,7 @@ description: "Review CometChat iOS UI Kit core features for messaging, media sha
{"name": "reactions", "description": "Let users react to messages with emojis", "component": "CometChatMessageList", "enabledByDefault": true},
{"name": "mentions", "description": "Tag users in messages with @mentions", "component": "CometChatMessageComposer", "enabledByDefault": true},
{"name": "threadedConversations", "description": "Reply to specific messages in threads", "component": "CometChatThreadedMessageHeader", "enabledByDefault": true},
+ {"name": "threadSubscription", "description": "Subscribe to or unsubscribe from a thread's replies", "component": "CometChatMessageList", "enabledByDefault": false},
{"name": "groupChat", "description": "Create and manage group conversations", "component": "CometChatGroups", "enabledByDefault": true},
{"name": "search", "description": "Search across conversations and messages", "component": "CometChatSearch", "enabledByDefault": true}
],
@@ -560,6 +561,40 @@ class ThreadedMessagesViewController: UIViewController {
**Component Used:**
- [ThreadedMessages](/ui-kit/ios/threaded-messages-header) - Displays thread replies
+## Thread Subscription
+
+Let users subscribe to a thread to be notified about its replies, or unsubscribe to mute it. Users are subscribed automatically when they start a thread, reply in one, or are @-mentioned in one — subscribing explicitly is how they opt in to a thread they haven't participated in yet.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+class ThreadSubscriptionViewController: UIViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let messageListView = CometChatMessageList()
+
+ // Off by default — opt in. Adds a "Subscribe to thread" /
+ // "Unsubscribe from thread" option to the message action sheet,
+ // on parent messages only.
+ messageListView.enableThreadSubscription = true
+ }
+
+ // Read the current state synchronously — no network call
+ func isSubscribed(parentMessageId: Int) -> Bool {
+ CometChat.threadSubscriptionState(forParentMessageId: parentMessageId) == .SUBSCRIBED
+ }
+}
+```
+
+**Component Used:**
+- [MessageList](/ui-kit/ios/message-list) - Hosts the subscribe/unsubscribe option
+
+See the [Thread Subscription guide](/ui-kit/ios/guide-thread-subscription) for the full feature.
+
## Group Chat
Create and manage group conversations.
@@ -672,6 +707,69 @@ class QuotedReplyViewController: UIViewController {
}
```
+## Pin Message
+
+Pin an important message so everyone in the conversation can find it. Pinning is restricted to group owners, admins and moderators; everyone else sees the pin indicator and the pinned list.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+class PinMessageViewController: UIViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let messageListView = CometChatMessageList()
+
+ // Pin is off by default — opt in, and the feature must also be
+ // enabled for your app (CometChat.isPinMessageEnabled()).
+ messageListView.enablePinMessage = true
+
+ // Open the per-conversation pinned list
+ let pinnedVC = CometChatPinnedMessages(group: group)
+ pinnedVC.set(onMessageClicked: { [weak self] message in
+ self?.navigationController?.popViewController(animated: true)
+ messageListView.goToMessage(withId: message.id)
+ })
+ navigationController?.pushViewController(pinnedVC, animated: true)
+ }
+}
+```
+
+See the [Pin & Save Messages guide](/ui-kit/ios/guide-pin-save-message) and [Pinned Messages](/ui-kit/ios/pinned-messages).
+
+## Save Message
+
+Save a message privately for later. Saves are visible only to the user who made them and span every conversation, so they are listed on a user-level screen rather than a per-conversation one.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+class SaveMessageViewController: UIViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let messageListView = CometChatMessageList()
+
+ // Save is off by default — opt in, and the feature must also be
+ // enabled for your app (CometChat.isSaveMessageEnabled()).
+ messageListView.enableSaveMessage = true
+
+ // Saved messages span every conversation, so open this from app chrome
+ let savedVC = CometChatSavedMessages()
+ savedVC.hideNavigationBar = false
+ navigationController?.pushViewController(savedVC, animated: true)
+ }
+}
+```
+
+See the [Pin & Save Messages guide](/ui-kit/ios/guide-pin-save-message) and [Saved Messages](/ui-kit/ios/saved-messages).
+
## Search
Search across conversations and messages.
diff --git a/ui-kit/ios/events.mdx b/ui-kit/ios/events.mdx
index 4be764cd2..9775981b2 100644
--- a/ui-kit/ios/events.mdx
+++ b/ui-kit/ios/events.mdx
@@ -360,6 +360,64 @@ CometChatConversationEvents.ccUpdateConversation(conversation: updatedConversati
| `ccConversationDeleted` | Conversation was deleted |
| `ccUpdateConversation` | Conversation was updated |
+## Thread Events
+
+Listen for thread subscription changes so any subscription control you host stays in agreement with the kit's action-sheet option, without a refetch.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+class ThreadedMessagesVC: UIViewController {
+
+ var parentMessage: BaseMessage?
+
+ // Listeners are keyed by id, and registering a duplicate id evicts the previous
+ // listener — so use a distinct id per screen.
+ private let threadListenerID = "threaded-messages-vc-\(UUID().uuidString)"
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ CometChatThreadEvents.addListener(threadListenerID, self)
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ CometChatThreadEvents.removeListener(threadListenerID)
+ }
+}
+
+extension ThreadedMessagesVC: CometChatThreadEventListener {
+
+ func ccThreadSubscriptionChanged(parentMessageId: Int, isSubscribed: Bool) {
+ // parentMessageId identifies the thread, not the message the tap came from
+ guard parentMessageId == parentMessage?.id else { return }
+ print(isSubscribed ? "Subscribed to thread" : "Unsubscribed from thread")
+ }
+}
+```
+
+### Emit Thread Events
+
+```swift lines
+// The local user subscribed to or unsubscribed from a thread
+CometChatThreadEvents.ccThreadSubscriptionChanged(parentMessageId: parentMessageId,
+ isSubscribed: true)
+```
+
+
+
+The kit emits this **only on success**, so every surface keeps showing the state the server still holds if a toggle fails.
+
+
+
+### Thread Events Reference
+
+| Event | Description |
+|-------|-------------|
+| `ccThreadSubscriptionChanged` | The local user subscribed to or unsubscribed from a thread. `parentMessageId` identifies the thread; `isSubscribed` is the new state. |
+
## Message Events
Listen for message-related actions like send, edit, delete, and reactions.
@@ -454,6 +512,16 @@ extension MessageEventsViewController: CometChatMessageEventListener {
print("Group video call: \(group.name ?? "")")
}
+ // Pin & Save
+ func ccMessagePinned(message: BaseMessage, status: MessageStatus) {
+ // There is no separate unpinned callback — read the field to tell which happened
+ print(message.pinnedAt != 0 ? "Pinned" : "Unpinned")
+ }
+
+ func ccMessageSaved(message: BaseMessage, status: MessageStatus) {
+ print(message.savedAt != 0 ? "Saved" : "Unsaved")
+ }
+
// Errors
func onMessageError(error: CometChatException) {
print("Error: \(error.errorDescription)")
@@ -484,6 +552,12 @@ CometChatMessageEvents.emitOnViewInformation(user: selectedUser)
// Thread parent updated
CometChatMessageEvents.emitOnParentMessageUpdate(message: parentMessage)
+
+// Message pinned or unpinned by the local user
+CometChatMessageEvents.ccMessagePinned(message: pinnedMessage, status: .success)
+
+// Message saved or unsaved by the local user
+CometChatMessageEvents.ccMessageSaved(message: savedMessage, status: .success)
```
### Message Events Reference
@@ -498,6 +572,8 @@ CometChatMessageEvents.emitOnParentMessageUpdate(message: parentMessage)
| `onMessageReact` | Reaction added to message |
| `onLiveReaction` | Live reaction sent |
| `onParentMessageUpdate` | Thread parent message updated |
+| `ccMessagePinned` | The local user pinned or unpinned a message. Read `message.pinnedAt` to tell which — non-zero means pinned. |
+| `ccMessageSaved` | The local user saved or unsaved a message. Read `message.savedAt` to tell which — non-zero means saved. |
| `onViewInformation` | Info button tapped (user or group) |
| `onVoiceCall` | Voice call initiated |
| `onVideoCall` | Video call initiated |
@@ -745,6 +821,8 @@ extension ChatEventManager: CometChatMessageEventListener {
func onVideoCall(group: Group) {}
func onMessageError(error: CometChatException) {}
func onMessageReply(message: BaseMessage, status: MessageStatus) {}
+ func ccMessagePinned(message: BaseMessage, status: MessageStatus) {}
+ func ccMessageSaved(message: BaseMessage, status: MessageStatus) {}
}
// MARK: - Call Events
diff --git a/ui-kit/ios/guide-overview.mdx b/ui-kit/ios/guide-overview.mdx
index 0a203929d..58e3d4381 100644
--- a/ui-kit/ios/guide-overview.mdx
+++ b/ui-kit/ios/guide-overview.mdx
@@ -29,6 +29,7 @@ Use these guides after completing [Getting Started](/ui-kit/ios/getting-started)
| [Group Ownership Transfer](/ui-kit/ios/guide-group-ownership) | Allow admins to hand over group ownership securely to another member. |
| [Message Privately](/ui-kit/ios/guide-message-privately) | Launch a direct 1:1 conversation from a group chat; streamlines side discussions without manual user searches. |
| [New Chat](/ui-kit/ios/guide-new-chat) | Offer a unified entry to discover users and groups and jump into new conversations quickly. |
+| [Pin & Save Messages](/ui-kit/ios/guide-pin-save-message) | Pin messages for everyone in a conversation and let users save messages privately, with role gating and dedicated list screens. |
| [Threaded Messages](/ui-kit/ios/guide-threaded-messages) | Add threaded reply views so users can branch focused discussions off individual messages. |
## Related Resources
diff --git a/ui-kit/ios/guide-pin-save-message.mdx b/ui-kit/ios/guide-pin-save-message.mdx
new file mode 100644
index 000000000..9b1ad8dbb
--- /dev/null
+++ b/ui-kit/ios/guide-pin-save-message.mdx
@@ -0,0 +1,283 @@
+---
+title: "Pin & Save Messages"
+sidebarTitle: "Pin & Save Messages"
+description: "Enable pin and save messages in the CometChat iOS UI Kit, wire the pinned and saved screens, gate pinning by role, and handle limit and permission errors."
+---
+
+Let users mark messages for later: **pin** a message so everyone in the conversation sees it, or **save** one privately for yourself.
+
+## Overview
+
+Pin and save look alike in the action sheet but behave differently:
+
+| | Pin | Save |
+|---|---|---|
+| Who sees it | Everyone in the conversation | Only you |
+| Scope | One conversation | All conversations |
+| Who can do it | Group owner, admin or moderator | Anyone |
+| Limit | 100 per conversation | 100 per user |
+| Confirmation | Asks first — it is public | Applies immediately |
+
+With the iOS UI Kit you get: the action-sheet options, pin and bookmark indicators on the bubble, and two full-screen list surfaces.
+
+## Prerequisites
+
+1. Completed [Getting Started](/ui-kit/ios/getting-started) setup
+2. CometChat UIKit v5+ installed
+3. User logged in with `CometChatUIKit.login()`
+4. Pin and save enabled for your app
+
+## Components
+
+| Component | Description |
+|-----------|-------------|
+| `CometChatMessageList` | Hosts the pin/save options and the bubble indicators |
+| `CometChatPinnedMessages` | Per-conversation list of pinned messages |
+| `CometChatSavedMessages` | User-level list of saved messages |
+| `CometChatMessageEvents` | Emits pin and save events to the rest of your app |
+
+## Integration Steps
+
+### Step 1: Turn the Features On
+
+Both features are **off by default**. Nothing appears in the action sheet until you opt in.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+let messageListView = CometChatMessageList()
+messageListView.set(user: user)
+
+// Opt in — both default to false
+messageListView.enablePinMessage = true
+messageListView.enableSaveMessage = true
+```
+
+
+Two switches must both be on. `enablePinMessage` is your app-side opt-in; the feature must **also** be enabled for your CometChat app, which the kit checks via `CometChat.isPinMessageEnabled()`. If the options do not appear, this is almost always why.
+
+
+You can verify the server-side flag directly:
+
+```swift lines
+if CometChat.isPinMessageEnabled() {
+ messageListView.enablePinMessage = true
+}
+
+if CometChat.isSaveMessageEnabled() {
+ messageListView.enableSaveMessage = true
+}
+```
+
+### Step 2: Find the Options
+
+Long-press a message to open the action sheet. Pin and save sit behind a **"More…"** row, which keeps the sheet from overflowing on smaller screens. Tapping it swaps the list, and tapping it again goes back.
+
+To change which options live behind that row, edit `MessageOptionConstants.overflowOptionIds`:
+
+```swift lines
+// Show pin inline and keep only save behind "More…"
+MessageOptionConstants.overflowOptionIds = [
+ MessageOptionConstants.saveMessage,
+ MessageOptionConstants.unsaveMessage
+]
+
+// Or show everything inline
+MessageOptionConstants.overflowOptionIds = []
+```
+
+Pinning shows a confirmation first, because it changes what everyone in the conversation sees. Saving applies immediately — it is private and one tap to undo.
+
+### Step 3: Add the Pinned Messages Screen
+
+Pinned messages belong to one conversation, so open this from that conversation — typically the message header's overflow menu.
+
+```swift lines
+private func openPinnedMessages() {
+ let pinnedVC = CometChatPinnedMessages(user: user, group: group)
+
+ // Tapping a row returns here and jumps to the message.
+ pinnedVC.set(onMessageClicked: { [weak self] message in
+ guard let self = self else { return }
+ self.navigationController?.popViewController(animated: true)
+ self.messageListView.goToMessage(withId: message.id)
+ })
+
+ navigationController?.pushViewController(pinnedVC, animated: true)
+}
+```
+
+See [Pinned Messages](/ui-kit/ios/pinned-messages) for the full component reference.
+
+### Step 4: Add the Saved Messages Screen
+
+Saved messages span every conversation, so this one belongs in your app's chrome — a tab, the chats-screen menu, or a profile entry. It takes no user or group.
+
+```swift lines
+private func openSavedMessages() {
+ let savedVC = CometChatSavedMessages()
+ savedVC.hidesBottomBarWhenPushed = true
+
+ // The component supplies its own back chevron, so leave hideBackButton alone.
+ savedVC.hideNavigationBar = false
+
+ savedVC.set(onBack: { [weak self] in
+ self?.navigationController?.setNavigationBarHidden(true, animated: true)
+ self?.navigationController?.popViewController(animated: true)
+ })
+
+ savedVC.set(onMessageClicked: { [weak self] message in
+ self?.openConversation(for: message)
+ })
+
+ navigationController?.setNavigationBarHidden(false, animated: true)
+ navigationController?.pushViewController(savedVC, animated: true)
+}
+```
+
+
+If you push this screen from a tab that hides the navigation bar, set `hideNavigationBar = false` or the title and back button never appear. Do **not** also set `hideBackButton = false` — the component supplies its own chevron, so clearing that flag renders two back buttons.
+
+
+Because a saved row can come from any chat, `onMessageClicked` has to resolve the conversation — see [Saved Messages](/ui-kit/ios/saved-messages) for that snippet.
+
+### Step 5: Handle Errors
+
+Failures roll the change back automatically and show a message. To react yourself, listen for the error codes on `PinSaveErrorCodes`:
+
+```swift lines
+switch error.errorCode {
+case PinSaveErrorCodes.pinLimitReached:
+ // Read the real cap from the server rather than hard-coding it
+ let limit = error.errorParams?["limit"] as? Int
+ print("Pin limit reached: \(limit ?? 0)")
+case PinSaveErrorCodes.pinPermissionDenied:
+ print("This user cannot pin here")
+default:
+ break
+}
+```
+
+
+Always read the cap from `errorParams["limit"]`. The limit is server-owned and hard-coding it means your copy goes stale the moment it changes.
+
+
+## Customization Options
+
+### Who Can Pin
+
+Pinning is gated by role, not authorship — a moderator can pin someone else's message, and a participant cannot pin their own. The kit uses `GroupMembersUtils.allowPinMessage(group:)`:
+
+| Scope | Can pin |
+|-------|---------|
+| Group owner | Yes |
+| Admin | Yes |
+| Moderator | Yes |
+| Participant | No |
+| One-to-one (`nil` group) | Yes |
+
+Saving has no role gate — anyone can save anything they can see.
+
+Use the same check to hide the unpin action for users who cannot pin:
+
+```swift lines
+pinnedVC.set(hideUnpinOption: !GroupMembersUtils.allowPinMessage(group: group))
+```
+
+### Hiding Individual Options
+
+`enable*` turns the feature on; `hide*` suppresses one option independently.
+
+```swift lines
+// Feature on, but no save option in this screen's action sheet
+messageListView.enablePinMessage = true
+messageListView.enableSaveMessage = true
+messageListView.hideSaveMessageOption = true
+```
+
+### Styling
+
+Both screens take their own style object built from theme tokens:
+
+```swift lines
+CometChatPinnedMessages.style.unpinActionBackgroundColor = CometChatTheme.errorColor
+CometChatSavedMessages.style.messageTypeImageTint = CometChatTheme.iconColorHighlight
+```
+
+### Reacting to Pin and Save Elsewhere
+
+Conform to `CometChatMessageEventListener` to update your own UI when the local user pins or saves:
+
+```swift lines
+extension MyViewController: CometChatMessageEventListener {
+
+ func ccMessagePinned(message: BaseMessage, status: MessageStatus) {
+ // No separate unpinned event — read the field to tell which happened
+ let isPinned = message.pinnedAt != 0
+ print(isPinned ? "Pinned" : "Unpinned")
+ }
+
+ func ccMessageSaved(message: BaseMessage, status: MessageStatus) {
+ let isSaved = message.savedAt != 0
+ print(isSaved ? "Saved" : "Unsaved")
+ }
+}
+```
+
+All listener methods have default empty implementations, so you only implement the ones you need. See [Events](/ui-kit/ios/events).
+
+## Edge Cases
+
+| Scenario | Handling |
+|----------|----------|
+| Action messages (group joins, calls) | No pin or save options — they are system chrome |
+| Deleted message | Options hidden; a pinned message that is deleted is auto-unpinned |
+| Message still sending | No options until the server assigns an id |
+| Moderation pending | No options until the message is approved |
+| Thread replies | Pinnable and savable; the pinned list shows the parent for context |
+| Edited message | Keeps its pin |
+| Participant in a group | Sees the pinned list and indicators, but no pin option |
+
+## Error Handling
+
+| Error code | Solution |
+|------------|----------|
+| `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` | Prompt the user to unpin something. Read the cap from `errorParams["limit"]`. |
+| `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` | Prompt the user to unsave something. |
+| `ERR_PERMISSION_DENIED` | The user's scope does not allow pinning; hide the option for them. |
+| `ERR_MESSAGE_NO_ACCESS` | The user can no longer access the message. |
+| `ERR_MESSAGE_ACTION_NOT_ALLOWED` | The action is not allowed on this message. |
+| `ERR_FEATURE_NOT_ACCESSIBLE` | The feature is not enabled for your app. |
+
+## Feature Matrix
+
+| Feature | Implementation |
+|---------|----------------|
+| Enable pin | `CometChatMessageList.enablePinMessage` |
+| Enable save | `CometChatMessageList.enableSaveMessage` |
+| Hide an option | `hidePinMessageOption` / `hideSaveMessageOption` |
+| Overflow row contents | `MessageOptionConstants.overflowOptionIds` |
+| Role gate | `GroupMembersUtils.allowPinMessage(group:)` |
+| Pinned list | `CometChatPinnedMessages(user:group:)` |
+| Saved list | `CometChatSavedMessages()` |
+| Jump to a message | `CometChatMessageList.goToMessage(withId:)` |
+| Error codes | `PinSaveErrorCodes` |
+| Events | `ccMessagePinned` / `ccMessageSaved` |
+
+## Related Components
+
+- [Pinned Messages](/ui-kit/ios/pinned-messages) - Per-conversation pinned list
+- [Saved Messages](/ui-kit/ios/saved-messages) - User-level saved list
+- [Message List](/ui-kit/ios/message-list) - Hosts the options and indicators
+- [Events](/ui-kit/ios/events) - Pin and save event callbacks
+
+
+
+ The underlying SDK methods and message fields
+
+
+ Overview of messaging features
+
+
diff --git a/ui-kit/ios/guide-thread-subscription.mdx b/ui-kit/ios/guide-thread-subscription.mdx
new file mode 100644
index 000000000..636409445
--- /dev/null
+++ b/ui-kit/ios/guide-thread-subscription.mdx
@@ -0,0 +1,148 @@
+---
+title: "Thread Subscription"
+sidebarTitle: "Thread Subscription"
+description: "Let users subscribe to or unsubscribe from message threads in the CometChat iOS UI Kit 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 the toggle as a **Subscribe to thread / Unsubscribe from thread** option in the message action sheet, and broadcasts every change on the event bus so any control you host elsewhere — such as a button in your thread screen's title bar — stays in sync.
+
+## Prerequisites
+
+- Threaded messages working in your app — see [Threaded Messages](/ui-kit/ios/guide-threaded-messages).
+- CometChat UI Kit for iOS with Chat SDK v5 or later.
+
+## Enable the Feature
+
+Thread subscription is **off by default**. Opt in per [CometChatMessageList](/ui-kit/ios/message-list) instance. When the gate is off, the option never renders and no subscription request is ever made.
+
+```swift
+let messageListView = CometChatMessageList()
+messageListView.enableThreadSubscription = true // opt in — default is false
+```
+
+Set it on **both** your main message list and the message list inside your thread screen, so the option is available in either place.
+
+## Surface 1: The Message Action Sheet Option
+
+With the gate on, `CometChatMessageList` adds a **Subscribe to thread** / **Unsubscribe from thread** option to the long-press action sheet. The label and icon reflect the current state, read synchronously from the SDK when the sheet is built.
+
+The option appears only on **parent messages** — never on a reply inside a thread:
+
+```swift
+// The kit's gate, for reference:
+// enableThreadSubscription && parentMessageId == 0 && !hideThreadSubscriptionOption
+```
+
+
+
+A subscription is always rooted at the thread's parent message. Offering the option on a reply would create a thread row the user can never open, so the kit hides it there entirely. It is **not** gated on reply count — subscribing to a message with no replies yet is the point.
+
+
+
+
+
+Unlike some other CometChat platforms, iOS offers thread subscription in **one-on-one conversations as well as groups** — there is no receiver-type check.
+
+
+
+To hide the option while keeping the rest of the feature:
+
+```swift
+messageListView.hideThreadSubscriptionOption = true
+```
+
+To replace the kit's behavior with your own, supply an `onItemClick` on a custom option with the id `MessageOptionConstants.threadSubscription` — the kit calls your handler instead of its own.
+
+## Surface 2: The Bell in the Message Header
+
+[CometChatMessageHeader](/ui-kit/ios/message-header) renders a subscribe/unsubscribe bell in its trailing area. Set `parentMessage` to put the header in **thread mode** — the bell renders only then, so a conversation header is unaffected.
+
+```swift
+let messageHeaderView = CometChatMessageHeader()
+messageHeaderView.set(parentMessage: parentMessage) // puts the header in thread mode
+messageHeaderView.enableThreadSubscription = true // off by default
+```
+
+The bell tracks state on its own: it reads the current subscription state from the SDK, flips optimistically on tap, reverts if the request fails, toasts in both directions, and emits `ccThreadSubscriptionChanged` on success. You do not wire any of that up.
+
+If your screen already draws its own control and you would otherwise show two bells, suppress the kit's:
+
+```swift
+messageHeaderView.hideThreadSubscriptionButton = true
+```
+
+
+
+The bell needs **both** `parentMessage` and `enableThreadSubscription`. Setting the flag alone on a conversation header renders nothing — that is deliberate, so turning the feature on globally cannot put a thread control on a non-thread screen.
+
+
+
+## Cross-Surface Sync
+
+Both surfaces observe the UI Kit event bus, so toggling in one place updates the other without a refetch. After a successful toggle the kit emits:
+
+```swift
+CometChatThreadEvents.ccThreadSubscriptionChanged(parentMessageId: parentMessageId,
+ isSubscribed: !isSubscribed)
+```
+
+`CometChatMessageHeader` observes this itself, so its bell stays correct when the user toggles from the action sheet — no wiring needed. Conform to `CometChatThreadEventListener` only to keep a control of **your own** in step:
+
+```swift
+extension ThreadedMessagesVC: CometChatThreadEventListener {
+ func ccThreadSubscriptionChanged(parentMessageId: Int, isSubscribed: Bool) {
+ guard parentMessageId == parentMessage?.id else { return }
+ renderThreadSubscription(isSubscribed: isSubscribed)
+ }
+}
+```
+
+
+
+Thread listeners are keyed by id, and registering a duplicate id **evicts** the previous listener. Use a distinct id per screen — the kit randomises its own for exactly this reason.
+
+
+
+See [Events](/ui-kit/ios/events) for the full event reference.
+
+## Behavior
+
+- **Optimistic with revert** — the control flips instantly on tap, keeps one request in flight per thread, and reverts if the server rejects the change. An offline tap fails visibly and reverts; nothing is queued.
+- **No event on failure** — the kit emits `ccThreadSubscriptionChanged` only on success, so every surface keeps showing the state the server still holds.
+- **Toasts in both directions** — subscribing and unsubscribing each confirm with a toast, and both toggle sites use the same copy.
+- **Unsubscribing is not sticky** — replying again, or being @-mentioned, re-subscribes the user. The kit says so in the toast rather than letting the user discover it.
+- **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.
+
+## Copy and Localization
+
+| Key | Default copy |
+| ---------------------------- | ------------------------------------------------------------------------ |
+| `THREAD_SUBSCRIBE` | Subscribe to thread |
+| `THREAD_UNSUBSCRIBE` | Unsubscribe from thread |
+| `THREAD_SUBSCRIBED` | Following |
+| `THREAD_SUBSCRIBED_TOAST` | Subscribed. You'll be notified about new replies in this thread. |
+| `THREAD_UNSUBSCRIBED_TOAST` | Unsubscribed. Notifications are off until you reply or are mentioned. |
+| `THREAD_SUBSCRIPTION_FAILED` | Couldn't update. Please try again. |
+| `THREAD_UNAVAILABLE` | You no longer have access to this thread. |
+
+
+
+`THREAD_SUBSCRIBED` is a VoiceOver-only label for the subscribed state — it is not shown as visible text.
+
+
+
+Override any of these in your own `Localizable.strings` — see [Localize](/ui-kit/ios/localize).
+
+## 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/ios/thread-subscription#notification-preferences).
+
+## Next Steps & Further Reading
+
+- [Thread Subscription (SDK)](/sdk/ios/thread-subscription) — the underlying APIs, including fetching the threads a user participates in to build a thread inbox.
+- [Threaded Messages Header](/ui-kit/ios/threaded-messages-header) — the full component reference.
+- [Message List](/ui-kit/ios/message-list) — action-sheet options.
diff --git a/ui-kit/ios/guide-threaded-messages.mdx b/ui-kit/ios/guide-threaded-messages.mdx
index c81acbd75..15198b5d0 100644
--- a/ui-kit/ios/guide-threaded-messages.mdx
+++ b/ui-kit/ios/guide-threaded-messages.mdx
@@ -228,6 +228,9 @@ navigationItem.leftBarButtonItem = UIBarButtonItem(
## Related Guides
+
+ Let users subscribe to or unsubscribe from a thread's replies
+
Customize the thread header component
diff --git a/ui-kit/ios/message-header.mdx b/ui-kit/ios/message-header.mdx
index fa64f531b..b806bde5c 100644
--- a/ui-kit/ios/message-header.mdx
+++ b/ui-kit/ios/message-header.mdx
@@ -47,8 +47,13 @@ The `CometChatMessageHeader` component displays user or group details in the too
"hideVoiceCallButton": { "type": "Bool", "default": false },
"hideNewChatButton": { "type": "Bool", "default": false },
"hideChatHistoryButton": { "type": "Bool", "default": false },
+ "hideThreadSubscriptionButton": { "type": "Bool", "default": false },
"disableTyping": { "type": "Bool", "default": false }
},
+ "threads": {
+ "parentMessage": { "type": "BaseMessage?", "default": "nil", "note": "Set to put the header in thread mode" },
+ "enableThreadSubscription": { "type": "Bool", "default": false, "note": "Also requires parentMessage" }
+ },
"style": {
"avatarStyle": { "type": "AvatarStyle", "default": "AvatarStyle()" },
"statusIndicatorStyle": { "type": "StatusIndicatorStyle", "default": "StatusIndicatorStyle()" },
@@ -891,6 +896,27 @@ let messageHeader = CometChatMessageHeader()
messageHeader.disableTyping = true
```
+### enableThreadSubscription
+
+Enables the subscribe/unsubscribe bell in the header's trailing area, letting users opt in to notifications for a thread's replies. The bell **also** requires `parentMessage` — setting this flag alone on a conversation header renders nothing, so turning the feature on globally cannot put a thread control on a non-thread screen.
+
+The bell manages itself: it reads the current state from the SDK, flips optimistically on tap, reverts on failure, toasts in both directions, and stays in step with a toggle made from the message action sheet.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageHeader = CometChatMessageHeader()
+messageHeader.set(parentMessage: parentMessage)
+messageHeader.enableThreadSubscription = true
+```
+
+See the [Thread Subscription guide](/ui-kit/ios/guide-thread-subscription) for the full feature.
+
### hideBackButton
Hides the back button in the header.
@@ -941,6 +967,15 @@ Hides the user status (online/offline/last active).
| Type | `Bool` |
| Default | `false` |
+### hideThreadSubscriptionButton
+
+Hides the subscribe/unsubscribe bell while leaving the feature on — for a screen that already draws its own control and would otherwise show two.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
### hideVideoCallButton
Hides the video call button.
@@ -999,6 +1034,22 @@ messageHeader.set(onAiNewChatClicked: { [weak self] in
})
```
+### parentMessage
+
+The thread's root message the subscription bell acts on. Setting it puts the header in **thread mode**; leaving it `nil` keeps the header in its normal conversation mode with no bell.
+
+| | |
+|---|---|
+| Type | `BaseMessage?` |
+| Default | `nil` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageHeader = CometChatMessageHeader()
+messageHeader.set(parentMessage: parentMessage)
+```
+
### statusIndicatorStyle
Customizes the appearance of the online/offline status indicator.
diff --git a/ui-kit/ios/message-list.mdx b/ui-kit/ios/message-list.mdx
index 16ec1009b..9f441fce2 100644
--- a/ui-kit/ios/message-list.mdx
+++ b/ui-kit/ios/message-list.mdx
@@ -56,6 +56,9 @@ The `CometChatMessageList` component displays a scrollable list of messages in a
"hideMessageInfoOption": { "type": "Bool", "default": false },
"hideTranslateMessageOption": { "type": "Bool", "default": false },
"hideMessagePrivatelyOption": { "type": "Bool", "default": false },
+ "hidePinMessageOption": { "type": "Bool", "default": false },
+ "hideSaveMessageOption": { "type": "Bool", "default": false },
+ "hideThreadSubscriptionOption": { "type": "Bool", "default": false },
"hideGroupActionMessages": { "type": "Bool", "default": false },
"hideNewMessageIndicator": { "type": "Bool", "default": false },
"hideEmptyView": { "type": "Bool", "default": false },
@@ -69,6 +72,9 @@ The `CometChatMessageList` component displays a scrollable list of messages in a
"scrollToBottomOnNewMessages": { "type": "Bool", "default": true },
"startFromUnreadMessages": { "type": "Bool", "default": false },
"showMarkAsUnreadOption": { "type": "Bool", "default": false },
+ "enablePinMessage": { "type": "Bool", "default": false, "note": "Also requires CometChat.isPinMessageEnabled()" },
+ "enableSaveMessage": { "type": "Bool", "default": false, "note": "Also requires CometChat.isSaveMessageEnabled()" },
+ "enableThreadSubscription": { "type": "Bool", "default": false, "note": "Offered on parent messages only, never on a reply" },
"messageAlignment": { "type": "MessageAlignment", "default": ".standard" }
},
"viewSlots": {
@@ -1471,6 +1477,58 @@ Enables AI-powered conversation summary feature.
| Type | `Bool` |
| Default | `false` |
+### enablePinMessage
+
+Enables the pin and unpin options in message actions. The feature must **also** be enabled for your app — the kit checks `CometChat.isPinMessageEnabled()` — and pinning is further gated by the user's scope.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageList = CometChatMessageList()
+messageList.enablePinMessage = true
+```
+
+### enableSaveMessage
+
+Enables the save and unsave options in message actions. The feature must **also** be enabled for your app — the kit checks `CometChat.isSaveMessageEnabled()`.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageList = CometChatMessageList()
+messageList.enableSaveMessage = true
+```
+
+### enableThreadSubscription
+
+Enables the **Subscribe to thread** / **Unsubscribe from thread** option in message actions, letting users opt in to notifications for a thread's replies. The option is offered on **parent messages only** — never on a reply inside a thread, because a subscription is always rooted at the thread's parent. It is not gated on reply count: subscribing to a message that has no replies yet is supported.
+
+Unlike some other CometChat platforms, iOS offers this in one-on-one conversations as well as groups.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageList = CometChatMessageList()
+messageList.enableThreadSubscription = true
+```
+
+See the [Thread Subscription guide](/ui-kit/ios/guide-thread-subscription) for the full feature, including keeping your own subscription control in sync.
+
### enableSmartReplies
Enables AI-powered smart reply suggestions.
@@ -1670,6 +1728,15 @@ Hides the new message indicator.
| Type | `Bool` |
| Default | `false` |
+### hidePinMessageOption
+
+Hides the pin option in message actions. Independent of `enablePinMessage` — use this to suppress the option on one screen while the feature stays on elsewhere.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
### hideReactionOption
Hides the reaction option on messages.
@@ -1713,6 +1780,38 @@ let messageList = CometChatMessageList()
messageList.hideReplyMessageOption = true
```
+### hideSaveMessageOption
+
+Hides the save option in message actions. Independent of `enableSaveMessage` — use this to suppress the option on one screen while the feature stays on elsewhere.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageList = CometChatMessageList()
+messageList.hideSaveMessageOption = true
+```
+
+### hideThreadSubscriptionOption
+
+Hides the subscribe/unsubscribe option in message actions. Independent of `enableThreadSubscription` — use this to suppress the option on one screen while the feature stays on elsewhere. Ignored while `enableThreadSubscription` is `false`, since nothing renders in that case anyway.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+import CometChatUIKitSwift
+
+let messageList = CometChatMessageList()
+messageList.hideThreadSubscriptionOption = true
+```
+
### hideShareMessageOption
Hides the share message option in message actions.
diff --git a/ui-kit/ios/message-template.mdx b/ui-kit/ios/message-template.mdx
index 6fff04600..7e67d3bee 100644
--- a/ui-kit/ios/message-template.mdx
+++ b/ui-kit/ios/message-template.mdx
@@ -834,6 +834,10 @@ The `template.options` method in the MessageTemplate allows you to customize the
However, if you wish to override or modify these options, you can use the `template.options` method and pass a list of `getMessageOptions`. This list of options will replace the default set.
+
+When pin and save are enabled, four more option ids appear in this list: `pinMessage`, `unpinMessage`, `saveMessage` and `unsaveMessage` (available as constants on `MessageOptionConstants`). By default they render behind a "More…" row rather than inline — control that with `MessageOptionConstants.overflowOptionIds`. See the [Pin & Save Messages guide](/ui-kit/ios/guide-pin-save-message).
+
+
diff --git a/ui-kit/ios/pinned-messages.mdx b/ui-kit/ios/pinned-messages.mdx
new file mode 100644
index 000000000..103ed03df
--- /dev/null
+++ b/ui-kit/ios/pinned-messages.mdx
@@ -0,0 +1,521 @@
+---
+title: "Pinned Messages"
+description: "Display and manage CometChat iOS UI Kit pinned messages for a conversation with per-row unpin, jump-to-message, custom view slots, and styling."
+---
+
+`CometChatPinnedMessages` is a full-screen list of the messages pinned in a single conversation, newest pin first. Pins are conversation-wide: everyone in the chat sees the same list, and moderators can unpin from any row.
+
+
+```json
+{
+ "component": "CometChatPinnedMessages",
+ "package": "CometChatUIKitSwift",
+ "import": "import CometChatUIKitSwift\nimport CometChatSDK",
+ "description": "Full-screen list of messages pinned in one conversation, ordered newest pin first",
+ "inherits": "CometChatListBase",
+ "primaryOutput": {
+ "callback": "onMessageClicked",
+ "type": "(BaseMessage) -> Void"
+ },
+ "props": {
+ "data": {
+ "user": { "type": "User?", "required": false, "note": "Pass user OR group, not both" },
+ "group": { "type": "Group?", "required": false },
+ "requestBuilder": { "type": "MessagesRequest.MessageRequestBuilder", "note": "Must retain set(pinned: true)" }
+ },
+ "callbacks": {
+ "onMessageClicked": "(BaseMessage) -> Void",
+ "onError": "(CometChatException) -> Void",
+ "onLoad": "([BaseMessage]) -> Void",
+ "onEmpty": "() -> Void",
+ "onBack": "() -> Void"
+ },
+ "visibility": {
+ "hideUnpinOption": { "type": "Bool", "default": false }
+ },
+ "styling": {
+ "style": { "type": "PinnedMessagesStyle" },
+ "messageBubbleStyle": { "type": "(incoming: MessageBubbleStyle, outgoing: MessageBubbleStyle)" },
+ "dateSeparatorStyle": { "type": "DateStyle" },
+ "avatarStyle": { "type": "AvatarStyle" }
+ },
+ "viewSlots": {
+ "titleView": "(BaseMessage?) -> UIView",
+ "subtitle": "(BaseMessage?) -> UIView",
+ "trailingView": "(BaseMessage?) -> UIView",
+ "listItemView": "(BaseMessage?) -> UIView"
+ }
+ },
+ "events": ["onMessagePinned", "onMessageUnpinned", "ccMessagePinned"],
+ "sdkListeners": ["CometChatConnectionDelegate"],
+ "compositionExample": {
+ "description": "Opened from the message header overflow menu, jumps the message list on row tap",
+ "components": ["CometChatMessageHeader", "CometChatPinnedMessages", "CometChatMessageList"],
+ "flow": "User taps ⋮ → Pinned messages → taps a row → returns to chat scrolled to that message"
+ }
+}
+```
+
+
+| Field | Value |
+|-------|-------|
+| Component | `CometChatPinnedMessages` |
+| Package | `CometChatUIKitSwift` |
+| Inherits | `CometChatListBase` |
+
+---
+
+## Where It Fits
+
+Pinned messages are scoped to one conversation, so this screen is opened from that conversation — typically the overflow menu in `CometChatMessageHeader`. Tapping a row takes the user back to the chat and scrolls to the message.
+
+Contrast with [Saved Messages](/ui-kit/ios/saved-messages), which is a user-level screen spanning every conversation and is reached from your app's chrome instead.
+
+
+Pin and save are **off by default**. Set `enablePinMessage` on your `CometChatMessageList` and enable the feature for your app, or no pin options appear and this screen stays empty. See the [Pin and Save Messages guide](/ui-kit/ios/guide-pin-save-message).
+
+
+This screen is read-only by design: opening it never marks anything as read, sends receipts, or changes the unread count.
+
+---
+
+## Minimal Render
+
+`CometChatPinnedMessages` is a view controller, so push it onto your navigation stack.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+// For a group conversation
+let pinnedVC = CometChatPinnedMessages(group: group)
+navigationController?.pushViewController(pinnedVC, animated: true)
+
+// For a one-to-one conversation
+let pinnedVC = CometChatPinnedMessages(user: user)
+navigationController?.pushViewController(pinnedVC, animated: true)
+```
+
+Pass either `user` or `group` — they are mutually exclusive and scope the list to that conversation.
+
+---
+
+## Filtering
+
+The list is fetched with a `MessagesRequest.MessageRequestBuilder`. The default comes from `PinnedMessagesBuilder`:
+
+```swift lines
+// The default builder used when you set nothing
+MessagesRequest.MessageRequestBuilder()
+ .set(limit: 100)
+ .set(pinned: true)
+```
+
+The server caps pins at 100 per conversation, so a limit of 100 fetches the entire list in one page.
+
+To narrow the list further, supply your own builder:
+
+```swift lines
+// MARK: - Only pinned text messages
+let requestBuilder = MessagesRequest.MessageRequestBuilder()
+ .set(limit: 100)
+ .set(pinned: true)
+ .set(guid: group.guid)
+ .set(types: ["text"])
+
+let pinnedVC = CometChatPinnedMessages(group: group)
+pinnedVC.set(requestBuilder: requestBuilder)
+```
+
+
+A custom request builder **must keep `set(pinned: true)`**. Without it the request returns every message in the conversation and the screen lists them all as though they were pinned.
+
+
+---
+
+## Actions and Events
+
+### Callback Props
+
+#### onMessageClicked
+
+Fires when a row is tapped. Use it to return to the conversation and jump to the message.
+
+```swift lines
+pinnedVC.set(onMessageClicked: { [weak self] message in
+ self?.navigationController?.popViewController(animated: true)
+ self?.messageListView.goToMessage(withId: message.id)
+})
+```
+
+#### onError
+
+Fires when the fetch fails, and again when an unpin fails.
+
+```swift lines
+pinnedVC.set(onError: { error in
+ print("Pinned messages error: \(error.errorCode)")
+})
+```
+
+#### onLoad
+
+Fires with the fetched messages each time the list reloads.
+
+```swift lines
+pinnedVC.set(onLoad: { messages in
+ print("Loaded \(messages.count) pinned messages")
+})
+```
+
+#### onEmpty
+
+Fires when the fetch completes with no pinned messages.
+
+```swift lines
+pinnedVC.set(onEmpty: {
+ print("No pinned messages in this conversation")
+})
+```
+
+#### onBack
+
+Inherited from `CometChatListBase`. The component ships a default that pops the navigation stack; set your own to replace it.
+
+```swift lines
+pinnedVC.set(onBack: { [weak self] in
+ self?.navigationController?.popViewController(animated: true)
+})
+```
+
+### Actions Reference
+
+| Action | Trigger | Default behavior |
+|--------|---------|------------------|
+| Row tap | User taps a pinned message | Calls `onMessageClicked`; no default navigation |
+| Unpin | User swipes a row from the trailing edge | Unpins the message and removes the row |
+| Back | User taps the back chevron | Pops the navigation stack |
+| Pull to refresh | User pulls the list down | Refetches the pinned list |
+
+### Global UI Events
+
+Pin and unpin actions performed elsewhere in the kit emit on `CometChatMessageEvents`. See [Events](/ui-kit/ios/events).
+
+| Event | Meaning |
+|-------|---------|
+| `ccMessagePinned` | The local user pinned or unpinned a message. Read `message.pinnedAt` to tell which — non-zero means pinned. |
+
+---
+
+## Custom View Slots
+
+Each row is a `CometChatMessageBubble` built from the same message templates the message
+list uses, so pinned photos, videos and files render as real bubbles. Rows are left-aligned
+regardless of sender — including your own, which keep their outgoing bubble color but sit on
+the left with an avatar and name. Each slot below receives the row's `BaseMessage` and
+returns a view that replaces one part of that bubble.
+
+### set(titleView:)
+
+Replaces the bubble's header, which carries the sender name by default.
+
+```swift lines
+pinnedVC.set(titleView: { message in
+ let label = UILabel()
+ label.text = message?.sender?.name ?? ""
+ label.font = .systemFont(ofSize: 16, weight: .semibold)
+ return label
+})
+```
+
+### set(subtitle:)
+
+Replaces the bubble's content — the rendered message body.
+
+```swift lines
+pinnedVC.set(subtitle: { message in
+ let label = UILabel()
+ label.text = (message as? TextMessage)?.text ?? ""
+ label.textColor = .secondaryLabel
+ return label
+})
+```
+
+### set(trailingView:)
+
+Replaces the bubble's status-info slot, which holds the timestamp and read receipt.
+
+### set(listItemView:)
+
+Replaces the entire bubble. Use this when the slots above are not enough.
+
+```swift lines
+pinnedVC.set(listItemView: { message in
+ let container = UIView()
+ // Build your own row layout
+ return container
+})
+```
+
+### Message templates
+
+To change how one message *type* renders, override its template rather than a slot. This
+keeps every other type on its default bubble.
+
+```swift lines
+pinnedVC.add(template: myCustomTextTemplate) // override a single category/type
+pinnedVC.set(templates: allMyTemplates) // replace every default
+```
+
+---
+
+## Styling
+
+### Style Hierarchy
+
+`PinnedMessagesStyle` conforms to `ListBaseStyle`, so it carries the standard screen-level properties plus the pin-specific ones. Bubble appearance is separate — set it through `messageBubbleStyle`.
+
+### Global Level Styling
+
+Applies to every instance created afterwards.
+
+```swift lines
+// MARK: - Apply global styling
+CometChatPinnedMessages.style.backgroundColor = UIColor(hex: "#F76808")
+CometChatPinnedMessages.style.unpinActionBackgroundColor = UIColor(hex: "#D92D20")
+```
+
+### Instance Level Styling
+
+```swift lines
+// MARK: - Apply instance-level styling
+var customStyle = PinnedMessagesStyle()
+customStyle.backgroundColor = UIColor(hex: "#F76808")
+customStyle.bubbleHeaderTextColor = CometChatTheme.textColorPrimary
+customStyle.unpinIconTint = UIColor(hex: "#FFFFFF")
+
+let pinnedVC = CometChatPinnedMessages(group: group)
+pinnedVC.set(style: customStyle)
+```
+
+### Key Style Properties
+
+| Property | Description | Default |
+|----------|-------------|---------|
+| `unpinIconTint` | Tint for the per-row unpin control. | `CometChatTheme.iconColorSecondary` |
+| `unpinActionBackgroundColor` | Background of the unpin swipe action. | `CometChatTheme.errorColor` |
+| `backgroundColor` | Screen background. | `CometChatTheme.backgroundColor01` |
+| `titleColor` | Navigation title color. | `CometChatTheme.textColorPrimary` |
+| `bubbleHeaderTextColor` | Sender-name color inside the bubble. | `CometChatTheme.primaryColor` |
+| `bubbleHeaderFont` | Sender-name font inside the bubble. | `CometChatTypography.Caption1.medium` |
+| `previewTextColor` | Preview color used by the pinned banner. | `CometChatTheme.textColorSecondary` |
+| `previewFont` | Preview font used by the pinned banner. | `CometChatTypography.Body.regular` |
+| `emptyTitleTextColor` | Empty-state title color. | `CometChatTheme.textColorPrimary` |
+| `errorTitleTextColor` | Error-state title color. | `CometChatTheme.textColorPrimary` |
+
+Bubbles are styled through `messageBubbleStyle`, the date dividers through `dateSeparatorStyle`, and avatars through `avatarStyle`:
+
+```swift lines
+pinnedVC.messageBubbleStyle.incoming.backgroundColor = UIColor(hex: "#E9EAEB")
+pinnedVC.dateSeparatorStyle.textColor = CometChatTheme.textColorSecondary
+pinnedVC.avatarStyle.cornerRadius = CometChatCornerStyle(cornerRadius: 24)
+```
+
+Rows group under a date divider per day, ordered newest pin first. Hide the dividers with
+`set(hideDateSeparator: true)`.
+
+### Customization Matrix
+
+| What to change | Where | Property/API |
+|----------------|-------|--------------|
+| Unpin swipe color | Style | `style.unpinActionBackgroundColor` |
+| Hide unpin entirely | Prop | `set(hideUnpinOption: true)` |
+| Bubble appearance | Style | `messageBubbleStyle` |
+| One message type's bubble | Template | `add(template:)` |
+| Whole row layout | View slot | `set(listItemView:)` |
+| Hide date dividers | Prop | `set(hideDateSeparator: true)` |
+| Bubble alignment | Prop | `set(messageAlignment:)` |
+| Which messages appear | Filter | `set(requestBuilder:)` |
+| Timestamp format | Formatter | `dateTimeFormatter` |
+| Row tap behavior | Callback | `set(onMessageClicked:)` |
+
+---
+
+## Props
+
+All props are optional. Sorted alphabetically.
+
+### avatarStyle
+
+Styling for the sender avatar beside incoming bubbles.
+
+| | |
+|---|---|
+| Type | `AvatarStyle` |
+| Default | `CometChatAvatar.style` |
+
+### dateSeparatorStyle
+
+Styling for the per-day date divider above each group of rows.
+
+| | |
+|---|---|
+| Type | `DateStyle` |
+| Default | `CometChatDate.style` |
+
+### messageBubbleStyle
+
+Appearance of the incoming and outgoing bubbles.
+
+| | |
+|---|---|
+| Type | `(incoming: MessageBubbleStyle, outgoing: MessageBubbleStyle)` |
+| Default | `CometChatMessageBubble.style` |
+
+### messageAlignment
+
+Every pinned message is left-aligned by default, the logged-in user's included, so each row
+is attributed by its avatar and sender name rather than by position. Set `.standard` to
+mirror the message list and align your own messages right.
+
+| | |
+|---|---|
+| Type | `MessageListAlignment` |
+| Default | `.leftAligned` |
+
+### hideDateSeparator
+
+Hides the per-day date dividers.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+### dateTimeFormatter
+
+Custom timestamp formatting.
+
+| | |
+|---|---|
+| Type | `CometChatDateTimeFormatter` |
+| Default | `CometChatUIKit.dateTimeFormatter` |
+
+### group
+
+The group whose pinned messages to show. Mutually exclusive with `user`; pass it to the initializer.
+
+| | |
+|---|---|
+| Type | `Group?` |
+| Default | `nil` |
+
+### hideUnpinOption
+
+Hides the per-row unpin swipe action. Set this for users who cannot pin in this conversation.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+pinnedVC.set(hideUnpinOption: true)
+```
+
+### style
+
+The component's style object.
+
+| | |
+|---|---|
+| Type | `PinnedMessagesStyle` |
+| Default | `PinnedMessagesStyle()` |
+
+### user
+
+The user whose pinned messages to show. Mutually exclusive with `group`; pass it to the initializer.
+
+| | |
+|---|---|
+| Type | `User?` |
+| Default | `nil` |
+
+---
+
+## Methods
+
+### set(requestBuilder:)
+
+Replaces the request used to fetch the list. Must retain `set(pinned: true)`.
+
+### set(textFormatters:)
+
+Applies custom text formatters to the message previews, matching the formatters used in your message list.
+
+```swift lines
+pinnedVC.set(textFormatters: [myCustomTextFormatter])
+```
+
+
+`PinnedMessagesViewModel` is public in name only — every member except `setRequestBuilder(requestBuilder:)` is internal. Customize through the props and view slots above rather than the view model.
+
+
+---
+
+## Common Patterns
+
+### Open from the message header and jump to the message
+
+The complete round trip: open the panel from the conversation, then return and scroll to the tapped message.
+
+```swift lines
+private func openPinnedMessages() {
+ let pinnedVC = CometChatPinnedMessages(user: user, group: group)
+
+ // Tapping a row returns to this conversation and jumps to the message.
+ pinnedVC.set(onMessageClicked: { [weak self] message in
+ guard let self = self else { return }
+ self.navigationController?.popViewController(animated: true)
+ self.messageListView.goToMessage(withId: message.id)
+ })
+
+ navigationController?.pushViewController(pinnedVC, animated: true)
+}
+```
+
+### Hide unpin for users without permission
+
+Pinning is restricted to group owners, admins and moderators. Hide the unpin action for everyone else so the swipe does not fail against the server.
+
+```swift lines
+let pinnedVC = CometChatPinnedMessages(group: group)
+pinnedVC.set(hideUnpinOption: !GroupMembersUtils.allowPinMessage(group: group))
+```
+
+### Custom empty state
+
+```swift lines
+let pinnedVC = CometChatPinnedMessages(group: group)
+pinnedVC.emptyStateTitleText = "Nothing pinned yet"
+pinnedVC.emptyStateSubTitleText = "Pin important messages to find them here."
+```
+
+---
+
+## Related Components
+
+- [Saved Messages](/ui-kit/ios/saved-messages) - The user-level saved messages screen
+- [Message List](/ui-kit/ios/message-list) - Where messages are pinned and unpinned
+- [Message Header](/ui-kit/ios/message-header) - Hosts the menu that opens this screen
+- [Events](/ui-kit/ios/events) - Pin and save event callbacks
+
+
+
+ End-to-end setup for pinning and saving
+
+
+ The underlying SDK methods and message fields
+
+
diff --git a/ui-kit/ios/saved-messages.mdx b/ui-kit/ios/saved-messages.mdx
new file mode 100644
index 000000000..4c59ccb1c
--- /dev/null
+++ b/ui-kit/ios/saved-messages.mdx
@@ -0,0 +1,455 @@
+---
+title: "Saved Messages"
+description: "Display CometChat iOS UI Kit saved messages for the logged-in user across every conversation, with per-row unsave, source labels, and tap to open the chat."
+---
+
+`CometChatSavedMessages` is a full-screen list of the messages the logged-in user has saved, newest save first. Saves are **private to the user** and **span every conversation**, so each row is labelled with the chat it came from.
+
+
+```json
+{
+ "component": "CometChatSavedMessages",
+ "package": "CometChatUIKitSwift",
+ "import": "import CometChatUIKitSwift\nimport CometChatSDK",
+ "description": "Full-screen, user-level list of saved messages spanning all conversations, newest save first",
+ "inherits": "CometChatListBase",
+ "primaryOutput": {
+ "callback": "onMessageClicked",
+ "type": "(BaseMessage) -> Void"
+ },
+ "props": {
+ "data": {
+ "requestBuilder": { "type": "MessagesRequest.MessageRequestBuilder", "note": "Must retain set(saved: true); takes no uid/guid" }
+ },
+ "callbacks": {
+ "onMessageClicked": "(BaseMessage) -> Void",
+ "onError": "(CometChatException) -> Void",
+ "onLoad": "([BaseMessage]) -> Void",
+ "onEmpty": "() -> Void",
+ "onBack": "() -> Void"
+ },
+ "visibility": {
+ "hideUnsaveOption": { "type": "Bool", "default": false }
+ },
+ "styling": {
+ "style": { "type": "SavedMessagesStyle" },
+ "dateStyle": { "type": "DateStyle" },
+ "avatarStyle": { "type": "AvatarStyle" }
+ },
+ "viewSlots": {
+ "titleView": "(BaseMessage?) -> UIView",
+ "subtitle": "(BaseMessage?) -> UIView",
+ "leadingView": "(BaseMessage?) -> UIView",
+ "trailingView": "(BaseMessage?) -> UIView",
+ "listItemView": "(BaseMessage?) -> UIView"
+ }
+ },
+ "events": ["onMessageSaved", "onMessageUnsaved", "ccMessageSaved"],
+ "sdkListeners": ["CometChatConnectionDelegate"],
+ "compositionExample": {
+ "description": "Opened from app chrome, not a conversation header, because saves are user-level",
+ "components": ["CometChatSavedMessages", "CometChatMessageList"],
+ "flow": "User opens Saved messages from the chats screen menu → taps a row → the source conversation opens"
+ }
+}
+```
+
+
+| Field | Value |
+|-------|-------|
+| Component | `CometChatSavedMessages` |
+| Package | `CometChatUIKitSwift` |
+| Inherits | `CometChatListBase` |
+
+---
+
+## Where It Fits
+
+
+This is a **user-level** screen, not a per-conversation one. It takes no `user` or `group` — it always shows the logged-in user's saves across every chat. Open it from your app's chrome (a tab, the chats-screen menu, a profile or settings entry), **not** from a conversation header.
+
+
+Contrast with [Pinned Messages](/ui-kit/ios/pinned-messages), which is scoped to one conversation and visible to everyone in it. Saves are private: only the saving user sees them, synced across that user's own devices.
+
+Pin and save are **off by default** — see the [Pin and Save Messages guide](/ui-kit/ios/guide-pin-save-message) to turn them on.
+
+This screen is read-only by design: opening it never marks anything as read, sends receipts, or changes the unread count.
+
+---
+
+## Minimal Render
+
+`CometChatSavedMessages` is a view controller with a no-argument initializer.
+
+```swift lines
+import UIKit
+import CometChatUIKitSwift
+import CometChatSDK
+
+let savedVC = CometChatSavedMessages()
+navigationController?.pushViewController(savedVC, animated: true)
+```
+
+
+If you push this screen from a tab that hides the navigation bar, set `hideNavigationBar = false` or the title and back button never appear.
+
+Do **not** also set `hideBackButton = false`. The component supplies its own back chevron as a left bar button item, so clearing that flag renders two back buttons side by side.
+
+
+```swift lines
+let savedVC = CometChatSavedMessages()
+savedVC.hidesBottomBarWhenPushed = true
+savedVC.hideNavigationBar = false
+
+navigationController?.setNavigationBarHidden(false, animated: true)
+navigationController?.pushViewController(savedVC, animated: true)
+```
+
+---
+
+## Filtering
+
+The list is fetched with a `MessagesRequest.MessageRequestBuilder`. The default comes from `SavedMessagesBuilder`:
+
+```swift lines
+// The default builder used when you set nothing
+MessagesRequest.MessageRequestBuilder()
+ .set(limit: 100)
+ .set(saved: true)
+```
+
+Unlike the pinned builder there is no `uid` or `guid` to set — saved messages are a per-user collection spanning every conversation. The server caps saves at 100, so a limit of 100 fetches the entire list in one page.
+
+```swift lines
+// MARK: - Only saved text messages
+let requestBuilder = MessagesRequest.MessageRequestBuilder()
+ .set(limit: 100)
+ .set(saved: true)
+ .set(types: ["text"])
+
+let savedVC = CometChatSavedMessages()
+savedVC.set(requestBuilder: requestBuilder)
+```
+
+
+A custom request builder **must keep `set(saved: true)`**. Without it the request returns every message the user can see and the screen lists them all as though they were saved.
+
+
+---
+
+## Actions and Events
+
+### Callback Props
+
+#### onMessageClicked
+
+Fires when a row is tapped. Because a row can belong to any conversation, use it to open that message's own chat — see [Common Patterns](#common-patterns).
+
+```swift lines
+savedVC.set(onMessageClicked: { [weak self] message in
+ self?.openConversation(for: message)
+})
+```
+
+#### onError
+
+Fires when the fetch fails, and again when an unsave fails.
+
+```swift lines
+savedVC.set(onError: { error in
+ print("Saved messages error: \(error.errorCode)")
+})
+```
+
+#### onLoad
+
+Fires with the fetched messages each time the list reloads.
+
+```swift lines
+savedVC.set(onLoad: { messages in
+ print("Loaded \(messages.count) saved messages")
+})
+```
+
+#### onEmpty
+
+Fires when the fetch completes with nothing saved.
+
+#### onBack
+
+Inherited from `CometChatListBase`. The component ships a default that pops the navigation stack; set your own to replace it — useful when you also need to re-hide the navigation bar on the way out.
+
+```swift lines
+savedVC.set(onBack: { [weak self] in
+ self?.navigationController?.setNavigationBarHidden(true, animated: true)
+ self?.navigationController?.popViewController(animated: true)
+})
+```
+
+### Actions Reference
+
+| Action | Trigger | Default behavior |
+|--------|---------|------------------|
+| Row tap | User taps a saved message | Calls `onMessageClicked`; no default navigation |
+| Unsave | User swipes a row from the trailing edge | Unsaves the message, removes the row, shows a toast |
+| Back | User taps the back chevron | Pops the navigation stack |
+| Pull to refresh | User pulls the list down | Refetches the saved list |
+
+### Global UI Events
+
+| Event | Meaning |
+|-------|---------|
+| `ccMessageSaved` | The local user saved or unsaved a message. Read `message.savedAt` to tell which — non-zero means saved. |
+
+See [Events](/ui-kit/ios/events) for the full listener surface.
+
+---
+
+## Custom View Slots
+
+Each slot receives the row's `BaseMessage` and returns a view that replaces the default.
+
+### set(titleView:)
+
+Replaces the row title, which by default is the **source conversation** name rather than the sender.
+
+```swift lines
+savedVC.set(titleView: { message in
+ let label = UILabel()
+ label.text = message?.sender?.name ?? ""
+ return label
+})
+```
+
+### set(subtitle:)
+
+Replaces the message-preview line.
+
+### set(leadingView:)
+
+Replaces the leading slot, which holds the source conversation's avatar by default.
+
+### set(trailingView:)
+
+Replaces the trailing slot, which holds the timestamp by default.
+
+### set(listItemView:)
+
+Replaces the entire row.
+
+---
+
+## Styling
+
+### Style Hierarchy
+
+`SavedMessagesStyle` conforms to `ListBaseStyle` and `ListItemStyle`, so it carries the standard list properties plus two save-specific ones.
+
+### Global Level Styling
+
+```swift lines
+// MARK: - Apply global styling
+CometChatSavedMessages.style.backgroundColor = UIColor(hex: "#F76808")
+CometChatSavedMessages.style.unsaveActionBackgroundColor = UIColor(hex: "#D92D20")
+```
+
+### Instance Level Styling
+
+```swift lines
+// MARK: - Apply instance-level styling
+var customStyle = SavedMessagesStyle()
+customStyle.backgroundColor = UIColor(hex: "#F76808")
+customStyle.messageTypeImageTint = CometChatTheme.iconColorHighlight
+
+let savedVC = CometChatSavedMessages()
+savedVC.set(style: customStyle)
+```
+
+### Key Style Properties
+
+| Property | Description | Default |
+|----------|-------------|---------|
+| `messageTypeImageTint` | Tint of the message-type glyph leading the preview (photo, video, document). | `CometChatTheme.iconColorSecondary` |
+| `unsaveActionBackgroundColor` | Background of the unsave swipe action. | `CometChatTheme.errorColor` |
+| `backgroundColor` | Screen background. | `CometChatTheme.backgroundColor01` |
+| `titleColor` | Navigation title color. | `CometChatTheme.textColorPrimary` |
+| `listItemTitleTextColor` | Row title color. | `CometChatTheme.textColorPrimary` |
+| `listItemTitleFont` | Row title font. | `CometChatTypography.Heading4.medium` |
+| `listItemSubTitleTextColor` | Row preview color. | `CometChatTheme.textColorSecondary` |
+| `listItemSubTitleFont` | Row preview font. | `CometChatTypography.Body.regular` |
+| `emptyTitleTextColor` | Empty-state title color. | `CometChatTheme.textColorPrimary` |
+| `errorTitleTextColor` | Error-state title color. | `CometChatTheme.textColorPrimary` |
+
+### Customization Matrix
+
+| What to change | Where | Property/API |
+|----------------|-------|--------------|
+| Unsave swipe color | Style | `style.unsaveActionBackgroundColor` |
+| Hide unsave entirely | Prop | `set(hideUnsaveOption: true)` |
+| Media glyph tint | Style | `style.messageTypeImageTint` |
+| Row layout | View slot | `set(listItemView:)` |
+| Which messages appear | Filter | `set(requestBuilder:)` |
+| Row tap behavior | Callback | `set(onMessageClicked:)` |
+
+---
+
+## Props
+
+All props are optional. Sorted alphabetically.
+
+### avatarStyle
+
+Styling for the row avatar, which shows the source conversation.
+
+| | |
+|---|---|
+| Type | `AvatarStyle` |
+| Default | `CometChatAvatar.style` |
+
+### dateStyle
+
+Styling for the row timestamp.
+
+| | |
+|---|---|
+| Type | `DateStyle` |
+| Default | `CometChatDate.style`, flattened to bare text |
+
+### dateTimeFormatter
+
+Custom timestamp formatting.
+
+| | |
+|---|---|
+| Type | `CometChatDateTimeFormatter` |
+| Default | `CometChatUIKit.dateTimeFormatter` |
+
+### hideUnsaveOption
+
+Hides the per-row unsave swipe action.
+
+| | |
+|---|---|
+| Type | `Bool` |
+| Default | `false` |
+
+```swift lines
+savedVC.set(hideUnsaveOption: true)
+```
+
+### style
+
+The component's style object.
+
+| | |
+|---|---|
+| Type | `SavedMessagesStyle` |
+| Default | `SavedMessagesStyle()` |
+
+---
+
+## Methods
+
+### set(requestBuilder:)
+
+Replaces the request used to fetch the list. Must retain `set(saved: true)`.
+
+### set(textFormatters:)
+
+Applies custom text formatters to the message previews.
+
+```swift lines
+savedVC.set(textFormatters: [myCustomTextFormatter])
+```
+
+
+`SavedMessagesViewModel` is public in name only — every member except `setRequestBuilder(requestBuilder:)` is internal. Customize through the props and view slots above rather than the view model.
+
+
+---
+
+## Common Patterns
+
+### Open from the chats screen menu
+
+Saves are user-level, so the entry point belongs in app chrome rather than a conversation.
+
+```swift lines
+private func openSavedMessages() {
+ let savedVC = CometChatSavedMessages()
+ savedVC.hidesBottomBarWhenPushed = true
+
+ // The component supplies its own back chevron, so leave hideBackButton alone.
+ savedVC.hideNavigationBar = false
+
+ savedVC.set(onBack: { [weak self] in
+ self?.navigationController?.setNavigationBarHidden(true, animated: true)
+ self?.navigationController?.popViewController(animated: true)
+ })
+
+ savedVC.set(onMessageClicked: { [weak self] message in
+ self?.openConversation(for: message)
+ })
+
+ // The chats screen hides the bar; unhide it before pushing.
+ navigationController?.setNavigationBarHidden(false, animated: true)
+ navigationController?.pushViewController(savedVC, animated: true)
+}
+```
+
+### Open the conversation a row came from
+
+A saved row can belong to any chat, so resolve the conversation from the message before navigating. For a group, look it up by `receiverUid`. For one-to-one, a **received** message is addressed to the logged-in user, so the conversation is with its sender.
+
+```swift lines
+private func openConversation(for message: BaseMessage) {
+ let openMessages: (CometChatSDK.User?, Group?) -> Void = { [weak self] user, group in
+ guard let self = self, user != nil || group != nil else { return }
+ let messages = MessagesVC()
+ messages.user = user
+ messages.group = group
+ self.navigationController?.pushViewController(messages, animated: true)
+ }
+
+ if message.receiverType == .group {
+ CometChat.getGroup(GUID: message.receiverUid) { group in
+ DispatchQueue.main.async { openMessages(nil, group) }
+ } onError: { _ in }
+ } else {
+ // A received 1:1 message is addressed to the logged-in user, so the
+ // conversation is with its sender; one they sent is addressed to the other party.
+ let isReceived = message.receiverUid == CometChat.getLoggedInUser()?.uid
+ let uid = isReceived ? (message.sender?.uid ?? message.receiverUid) : message.receiverUid
+
+ CometChat.getUser(UID: uid) { user in
+ DispatchQueue.main.async { openMessages(user, nil) }
+ } onError: { _ in }
+ }
+}
+```
+
+### Custom empty state
+
+```swift lines
+let savedVC = CometChatSavedMessages()
+savedVC.emptyStateTitleText = "Nothing saved yet"
+savedVC.emptyStateSubTitleText = "Save messages to read them later."
+```
+
+---
+
+## Related Components
+
+- [Pinned Messages](/ui-kit/ios/pinned-messages) - The per-conversation pinned messages screen
+- [Message List](/ui-kit/ios/message-list) - Where messages are saved and unsaved
+- [Conversations](/ui-kit/ios/conversations) - The chats screen that hosts the entry point
+- [Events](/ui-kit/ios/events) - Pin and save event callbacks
+
+
+
+ End-to-end setup for pinning and saving
+
+
+ The underlying SDK methods and message fields
+
+