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 + +