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