diff --git a/docs.json b/docs.json index 2ee65ea2a..0426be32c 100644 --- a/docs.json +++ b/docs.json @@ -1867,13 +1867,13 @@ "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", "ui-kit/android/guide-call-log-details", "ui-kit/android/guide-group-chat", + "ui-kit/android/guide-text-color", "ui-kit/android/custom-text-formatter-guide", "ui-kit/android/mentions-formatter-guide", "ui-kit/android/shortcut-formatter-guide", @@ -6979,6 +6979,10 @@ "source": "/ui-kit/react/property-changes", "destination": "/ui-kit/react/migration-property-changes" }, + { + "source": "/ui-kit/android/guide-thread-subscription", + "destination": "/ui-kit/android/guide-threaded-messages#thread-subscription" + }, { "source": "/ui-kit/android/property-changes", "destination": "/ui-kit/android/upgrading-from-v4" diff --git a/images/pin.png b/images/pin.png new file mode 100644 index 000000000..56e141e60 Binary files /dev/null and b/images/pin.png differ diff --git a/images/save.png b/images/save.png new file mode 100644 index 000000000..ab78df01b Binary files /dev/null and b/images/save.png differ diff --git a/ui-kit/android/conversations.mdx b/ui-kit/android/conversations.mdx index 1e71ad293..1b481e32c 100644 --- a/ui-kit/android/conversations.mdx +++ b/ui-kit/android/conversations.mdx @@ -165,6 +165,15 @@ CometChatConversations( | With tags | `.setTags(listOf("vip")).withTags(true)` | | Filter by user tags | `.withUserAndGroupTags(true).setUserTags(listOf("premium"))` | | Filter by group tags | `.withUserAndGroupTags(true).setGroupTags(listOf("support"))` | +| Pinned conversations only | `.setPinnedBy("system,me")` | + +The default list already arrives pin-ordered, so `setPinnedBy` is only for a **dedicated pinned list** — `"me"` for the user's own pins, `"system"` for admin/global pins, `"system,me"` for both. See [Pin A Conversation (SDK)](/sdk/android/v5/pin-conversation). + +```kotlin lines +conversations.setConversationsRequestBuilder( + ConversationsRequest.ConversationsRequestBuilder().setPinnedBy("system,me") +) +``` Pass the builder object, not the result of `.build()`. The component calls `.build()` internally. Default page size is 30 with infinite scroll. @@ -419,6 +428,52 @@ The component listens to these SDK events internally. No manual setup needed. --- +## Pinning Conversations + +A pinned conversation sits at the top of the list and holds that position even as new messages arrive in other chats. The pin is **private to the logged-in user** — nobody else sees it — and it syncs to that user's other devices. + +When the feature is enabled for your app, the long-press menu includes **Pin conversation** / **Unpin conversation** with no wiring needed: pinning applies immediately with a toast, unpinning asks for confirmation first, and a pinned row shows a pin indicator next to its timestamp. + + +**Pin Conversation must be enabled for your app** through the `features.ux.conversations.pinned.enabled` app setting. Until it is, the option never renders, and a direct SDK call rejects with `ERR_FEATURE_NOT_ACCESSIBLE`. The UI Kit reads that setting itself at login and on every reconnect — there is no app code to write. Read it yourself with `CometChatUIKit.isPinConversationEnabled()` when you need to gate your own entry point. + + +`setPinConversationOptionVisibility(View.GONE)` (Compose: `hidePinOption = true`) hides the option on one particular list. It is **ANDed** with the Dashboard flag, so the option renders only when both allow it. + + + +```kotlin lines +conversations.setPinConversationOptionVisibility(View.GONE) +``` + + +```kotlin lines +CometChatConversations( + hidePinOption = true +) +``` + + + +### Reading pin state + +```kotlin lines +val isPinned = conversation.isPinned // pinnedAt > 0 + +// An admin/global pin. A user can neither pin nor unpin one of these +// (the server rejects it with ERR_SYSTEM_PINNED_CONVERSATION), so hide +// or disable the pin/unpin control. +val isSystemPinned = conversation.isSystemPinned // pinnedBy == "app_system" +``` + + +The conversation list arrives **already pin-ordered** from the server — admin pins first, then the user's own, then everything else — and each row carries its pin attributes. You do not need a separate fetch to render a pinned section. To render a pinned-**only** list, use the `setPinnedBy` filter shown in [Filtering Conversations](#filtering-conversations). + + +For pins made elsewhere, the Chat SDK exposes `CometChat.ConversationListener` with `onConversationPinned` / `onConversationUnpinned`. See [Pin A Conversation (SDK)](/sdk/android/v5/pin-conversation) for those callbacks, the pin methods, and the per-user pin limit underneath. + +--- + ## Functionality | Method (Kotlin XML) | Compose Parameter | Description | @@ -431,7 +486,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 | +| `setPinConversationOptionVisibility(View.GONE)` | `hidePinOption = true` | Hide the built-in Pin/Unpin conversation option | --- @@ -709,18 +764,6 @@ 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 3bf9d7ec0..664ff14a6 100644 --- a/ui-kit/android/core-features.mdx +++ b/ui-kit/android/core-features.mdx @@ -147,7 +147,16 @@ Keep important messages in reach. Pinning highlights a message for **everyone** | [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. +Each feature is switched on per app, and each carries its own cap: + +| App setting | Controls | +| --- | --- | +| `features.ux.messages.pinned.enabled` | Whether the pin options and screen render at all | +| `features.ux.messages.pinned.limit` | Pinned messages per conversation | +| `features.ux.messages.saved.enabled` | Whether the save options and screen render at all | +| `features.ux.messages.saved.limit` | Saved messages per user | + +The UI Kit reads the feature flags at login, so no wiring is needed to show or hide the options. When a user reaches a cap, the kit shows a toast naming the limit — no extra handling required. For a full walkthrough, see the [Pin & Save Messages guide](/ui-kit/android/guide-pin-and-save-messages). ## Pin Conversations @@ -155,7 +164,16 @@ Keep the chats that matter at the top. Users pin a conversation from the long-pr | Component | Role | | --- | --- | -| [CometChatConversations](/ui-kit/android/conversations) | Provides the Pin/Unpin conversation option, the row indicator, and pinned-first ordering. | +| [CometChatConversations](/ui-kit/android/conversations#pinning-conversations) | Provides the Pin/Unpin conversation option, the row indicator, and pinned-first ordering. | + +Conversation pinning is switched on per app, and carries its own cap: + +| App setting | Controls | +| --- | --- | +| `features.ux.conversations.pinned.enabled` | Whether the pin/unpin conversation option renders | +| `features.ux.conversations.pinned.limit` | Pinned conversations per user | + +A conversation can also be **system-pinned** app-wide by your app. System pins always sort above user pins, and a user can neither pin nor unpin them. See [Conversations → Pinning Conversations](/ui-kit/android/conversations#pinning-conversations). ## Rich Text Formatting @@ -169,6 +187,7 @@ Rich Text Formatting allows users to style their messages with bold, italic, str | --- | --- | | [CometChatMessageComposer](/ui-kit/android/message-composer) | Provides a built-in rich text editor with formatting toolbar and text selection menu items for bold, italic, strikethrough, code, links, lists, blockquotes, and code blocks. | | [CometChatMessageList](/ui-kit/android/message-list) | Renders formatted messages with the appropriate styling automatically applied, ensuring that rich text formatting is displayed exactly as intended by the sender. | + ## Threaded Conversations Respond directly to a specific message, keeping conversations organized. @@ -183,16 +202,16 @@ 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 +### 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)`. +Let users subscribe to or unsubscribe from a thread to control whether its replies notify them. Enabled by default from **v6.1.0** — remove a surface with `setThreadSubscriptionOptionVisibility(View.GONE)` or `setThreadSubscriptionVisibility(View.GONE)`. On **v6.0.x** the feature is opt-in via `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. +Subscriptions are also created automatically by the **server**, so they apply to any app on the SDK, not just the UI Kit: sending a message subscribes you to that message's own thread, posting a reply subscribes you to that thread, and being @mentioned in a reply subscribes you. The kit reflects these the moment they happen. See [Threaded Messages → Thread Subscription](/ui-kit/android/guide-threaded-messages#thread-subscription) for setup and behavior. ## Quoted Replies diff --git a/ui-kit/android/custom-text-formatter-guide.mdx b/ui-kit/android/custom-text-formatter-guide.mdx index 9846d9ad3..c923eaaea 100644 --- a/ui-kit/android/custom-text-formatter-guide.mdx +++ b/ui-kit/android/custom-text-formatter-guide.mdx @@ -1,6 +1,6 @@ --- title: "Custom Text Formatter" -sidebarTitle: "Custom Text Formatter" +sidebarTitle: "Text Formatter Base Class" description: "Extend CometChatTextFormatter to build custom inline text patterns with tracking characters and suggestion lists." --- diff --git a/ui-kit/android/events.mdx b/ui-kit/android/events.mdx index 52ad4d279..20e251557 100644 --- a/ui-kit/android/events.mdx +++ b/ui-kit/android/events.mdx @@ -181,7 +181,7 @@ lifecycleScope.launch { } ``` -See the [Thread Subscription guide](/ui-kit/android/guide-thread-subscription) for the feature end to end. +See [Threaded Messages → Thread Subscription](/ui-kit/android/guide-threaded-messages#thread-subscription) for the feature end to end. ### Call Events diff --git a/ui-kit/android/guide-overview.mdx b/ui-kit/android/guide-overview.mdx index 51f75b5ac..dfce74e05 100644 --- a/ui-kit/android/guide-overview.mdx +++ b/ui-kit/android/guide-overview.mdx @@ -41,7 +41,8 @@ The CometChat Android UI Kit is available in two modules: | [Threaded Messages](/ui-kit/android/guide-threaded-messages) | Threaded replies: open parent message context, list replies, compose with parent linkage. | | [Search Messages](/ui-kit/android/guide-search-messages) | Full-text message search across conversations with result routing and navigation. | | [AI Agent](/ui-kit/android/guide-ai-agent) | Build an AI-powered agent that responds to user messages using CometChat's AI features. | -| [Custom Text Formatter](/ui-kit/android/custom-text-formatter-guide) | Extend `CometChatTextFormatter` to build custom inline text patterns with tracking characters and suggestion lists. | +| [Color Selected Text](/ui-kit/android/guide-text-color) | Add a color button to the composer toolbar that colors selected text, rendered in the sent message. | +| [Text Formatter Base Class](/ui-kit/android/custom-text-formatter-guide) | Extend `CometChatTextFormatter` to build custom inline text patterns with tracking characters and suggestion lists. | | [Mentions Formatter](/ui-kit/android/mentions-formatter-guide) | Format @mentions with styled tokens, suggestion lists, and click handling. | | [ShortCut Formatter](/ui-kit/android/shortcut-formatter-guide) | Add shortcut text expansion to the message composer via the message-shortcuts extension. | diff --git a/ui-kit/android/guide-pin-and-save-messages.mdx b/ui-kit/android/guide-pin-and-save-messages.mdx index 3a8d6a1c1..f2a2adcb6 100644 --- a/ui-kit/android/guide-pin-and-save-messages.mdx +++ b/ui-kit/android/guide-pin-and-save-messages.mdx @@ -144,9 +144,40 @@ conversations.setPinConversationOptionVisibility(View.GONE) +## App Settings and Limits + +Each feature is switched on per app, and each carries a cap on how many items a user may pin or save. Both are configured as app settings in the dashboard: + +| Setting | Controls | +| --- | --- | +| `features.ux.messages.pinned.enabled` | Whether the pin options and screen render at all | +| `features.ux.messages.pinned.limit` | Pins per conversation | +| `features.ux.messages.saved.enabled` | Whether the save options and screen render at all | +| `features.ux.messages.saved.limit` | Saves per user | +| `features.ux.conversations.pinned.enabled` | Whether the pin/unpin conversation option renders | +| `features.ux.conversations.pinned.limit` | Pinned conversations per user (see [Conversations](/ui-kit/android/conversations#pinning-conversations)) | + +When a user hits a cap, the UI Kit shows a toast explaining the limit — you don't need to handle the error yourself. + +### Error Codes + +The built-in options turn these rejections into toasts. If you build custom pin or save UI on the SDK, handle them yourself: + +| Code | Meaning | +| --- | --- | +| `ERR_PERMISSION_DENIED` | The acting user's role may not pin or unpin here. Pin and unpin are gated independently. | +| `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` | The conversation's pin cap was reached. | +| `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` | The user's save cap was reached. | +| `ERR_PINNED_CONVERSATIONS_LIMIT_EXCEEDED` | The user's conversation pin cap was reached. | +| `ERR_SYSTEM_PINNED_CONVERSATION` | The conversation is **system-pinned** for all users by the app, so a user can neither pin nor unpin it. | +| `ERR_MESSAGE_NO_ACCESS` | The user has no access to that message — for example they are not a participant in its conversation. | +| `ERR_FEATURE_NOT_ACCESSIBLE` | The feature is not enabled for the app. | + +The limit and system-pin codes are also listed in the [Error Guide](/articles/error-guide). + ## 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). +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 message 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 diff --git a/ui-kit/android/guide-text-color.mdx b/ui-kit/android/guide-text-color.mdx new file mode 100644 index 000000000..5fe6905c7 --- /dev/null +++ b/ui-kit/android/guide-text-color.mdx @@ -0,0 +1,668 @@ +--- +title: "Color Selected Text" +sidebarTitle: "Color Selected Text" +description: "Add a color button to the composer's rich-text toolbar that colors the selected text, and render that color in the sent message." +--- + + + +| Field | Value | +| --- | --- | +| Packages | `com.cometchat:chatuikit-kotlin` · `com.cometchat:chatuikit-jetpack` | +| Key classes | `RichTextToolbarTrailingViewListener` · `ComposerInputController` · `CometChatTextFormatter` | +| Required setup | `CometChatUIKit.init()` then `CometChatUIKit.login("UID")` | +| Purpose | Wrap the composer selection in a `{color:#rrggbb}…{/color}` token and render it as colored text everywhere | +| Related | [Message Composer](/ui-kit/android/message-composer#rich-text-toolbar-trailing-buttons) \| [Text Formatter Base Class](/ui-kit/android/custom-text-formatter-guide) \| [All Guides](/ui-kit/android/guide-overview) | + + + +## Goal + +By the end of this guide you will have a **color button** at the trailing end of the composer's rich-text toolbar. The user selects some text, picks a color, and the text turns that color — in the composer while typing, and in the message bubble after it is sent. + +Android's built-in `RichTextFormat` set covers bold, italic, underline, strikethrough, code, lists, blockquote and links — but **not color**. So the work splits into two pieces: + +1. **Authoring** — a button in the composer's rich-text toolbar trailing slot that wraps the current selection in a color token through the `ComposerInputController`. +2. **Rendering** — a `CometChatTextFormatter` that turns that token into colored text on every surface, and styles it live in the composer. + + +This guide builds color on top of the formatter base class. For the base class itself — tracking characters, suggestion lists, `handlePreMessageSend` — see the [Text Formatter Base Class](/ui-kit/android/custom-text-formatter-guide) guide. + + +## Prerequisites + +- Completed the [Getting Started](/ui-kit/android/getting-started) guide +- A chat screen using `CometChatMessageList` and `CometChatMessageComposer` +- Rich text formatting enabled on the composer — `setEnableRichTextFormatting(true)` / `enableRichTextFormatting = true` + + +The trailing slot lives **inside** the rich-text toolbar. It is not rendered when the toolbar is hidden or the rich-text editor is disabled, so the button disappears along with Bold and Italic. + + +## Step 1: The Formatter + +Extend `CometChatTextFormatter`. Our token is `{color:#hex}…{/color}` — plain text in the message body, which the formatter turns into colored text. Two details make it a rendering-only formatter rather than a suggestion one: a **private-use tracking character** (`'\uE000'`) that a user can never type, and `setDisableSuggestions(true)`. + +The formatter does two jobs: + +- `prepare*Span()` — strips the markers and applies the color on bubbles, conversation subtitles and reply/edit previews. +- `applyComposerSpans()` (XML) / `composerVisualTransformation()` (Compose) — renders the token **in place** in the live input, so the user sees color while typing. Both are display-only: the token characters stay in the field, so they still go on the wire. + + +`prepareComposerSpan()` stays **identity** on purpose. Editing an existing message reads that text back into the input, so stripping the token there would drop the color from the composer and from the re-sent message. + + + + + +The markers are hidden with a zero-width `ReplacementSpan` — it draws nothing and reports width `0`, while leaving the characters in the `Editable`. + +_File: ColorComposerSpans.kt_ + +```kotlin lines +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ForegroundColorSpan +import android.text.style.ReplacementSpan + +/** + * Zero-width span used to HIDE a colour token's markers (`{color:#…}` / `{/color}`) in the live + * composer while keeping the characters in the Editable, so the token still goes on the wire. + */ +class ColorMarkerSpan : ReplacementSpan() { + override fun getSize( + paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt? + ): Int = 0 + + override fun draw( + canvas: Canvas, text: CharSequence?, start: Int, end: Int, + x: Float, top: Int, y: Int, bottom: Int, paint: Paint + ) { /* draw nothing — the markers are hidden */ } +} + +/** Marker subclass so the formatter can find and remove its own colour spans idempotently. */ +class ColorContentSpan(color: Int) : ForegroundColorSpan(color) +``` + +_File: ColorFormatter.kt_ + +```kotlin lines +import android.content.Context +import android.text.Editable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import com.cometchat.chat.models.BaseMessage +import com.cometchat.uikit.kotlin.shared.formatters.CometChatTextFormatter + +class ColorFormatter : CometChatTextFormatter(TRACK) { + + companion object { + // Private-use char: never typed, so this formatter never triggers suggestion tracking. + private const val TRACK = '\uE000' + private val TOKEN = + Regex("""\{color:(#[0-9a-fA-F]{6})\}(.*?)\{/color\}""", RegexOption.DOT_MATCHES_ALL) + } + + init { setDisableSuggestions(true) } + + override fun search(context: Context, queryString: String?) {} + override fun onScrollToBottom() {} + override fun getDisableSuggestions(): Boolean = true + + /** The stored token is what goes on the wire — no reverse transform needed. */ + override fun getOriginalText(text: String): String = text + + /** + * Live WYSIWYG rendering in the composer field: colour the inner text and HIDE the markers with + * a zero-width span (span-only — the token text stays in the Editable, so it still sends). + * Removes its own prior spans first so repeated calls are idempotent. + */ + override fun applyComposerSpans(editable: Editable) { + editable.getSpans(0, editable.length, ColorContentSpan::class.java) + .forEach { editable.removeSpan(it) } + editable.getSpans(0, editable.length, ColorMarkerSpan::class.java) + .forEach { editable.removeSpan(it) } + + for (m in TOKEN.findAll(editable.toString())) { + val hex = m.groupValues[1] + val inner = m.groupValues[2] + val openStart = m.range.first + val innerStart = openStart + "{color:$hex}".length + val innerEnd = innerStart + inner.length + val closeEnd = m.range.last + 1 + if (innerEnd > innerStart) { + parseColor(hex)?.let { + editable.setSpan( + ColorContentSpan(it), innerStart, innerEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + editable.setSpan(ColorMarkerSpan(), openStart, innerStart, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + editable.setSpan(ColorMarkerSpan(), innerEnd, closeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + private fun parseColor(hex: String): Int? = try { + android.graphics.Color.parseColor(hex) + } catch (e: IllegalArgumentException) { null } + + /** + * Strips each `{color:#…}…{/color}` token's markers in place and colours the inner text. + * Processed right-to-left so earlier match offsets stay valid across the in-place deletes; + * spans set before the leading-marker delete shift left automatically with the text. + */ + private fun render(sb: SpannableStringBuilder): SpannableStringBuilder { + val raw = sb.toString() + if (!raw.contains("{color:")) return sb + for (m in TOKEN.findAll(raw).toList().asReversed()) { + val hex = m.groupValues[1] + val inner = m.groupValues[2] + val openStart = m.range.first + val innerStart = openStart + "{color:$hex}".length + val innerEnd = innerStart + inner.length + val closeEnd = m.range.last + 1 + // 1) delete trailing {/color} + sb.delete(innerEnd, closeEnd) + // 2) colour the inner range (still at its original offsets) + parseColor(hex)?.let { + sb.setSpan(ForegroundColorSpan(it), innerStart, innerEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + // 3) delete leading {color:#hex} — the span shifts left with the text + sb.delete(openStart, innerStart) + } + return sb + } + + // Composer span is IDENTITY: the live field renders colour via applyComposerSpans(), and + // edit-populate reads this text back into the input — so the token must be kept intact here. + override fun prepareComposerSpan( + context: Context, baseMessage: BaseMessage, spannable: SpannableStringBuilder + ) = spannable + + override fun prepareLeftMessageBubbleSpan( + context: Context, baseMessage: BaseMessage, spannable: SpannableStringBuilder + ) = render(spannable) + + override fun prepareRightMessageBubbleSpan( + context: Context, baseMessage: BaseMessage, spannable: SpannableStringBuilder + ) = render(spannable) + + override fun prepareConversationSpan( + context: Context, baseMessage: BaseMessage, spannable: SpannableStringBuilder + ) = render(spannable) + + // Preview panels are display surfaces → strip markers + colour (unlike the identity composer span). + override fun preparePreviewSpan( + context: Context, baseMessage: BaseMessage, spannable: SpannableStringBuilder + ) = render(spannable) +} +``` + + + + +Compose renders the live input through a `VisualTransformation`. It genuinely **removes** the marker characters from the displayed text, so it must also supply an `OffsetMapping` — otherwise the caret and selection drift by the length of every hidden marker. + +_File: ColorTokenVisualTransformation.kt_ + +```kotlin lines +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +/** + * Live-composer transformation for the `{color:#…}…{/color}` token: hides the markers and colours + * the inner text right in the editable field, WYSIWYG. Display-only — the underlying text (with the + * token) is unchanged, so the token still goes on the wire and renders on every surface. + */ +class ColorTokenVisualTransformation : VisualTransformation { + + private val token = + Regex("""\{color:(#[0-9a-fA-F]{6})\}(.*?)\{/color\}""", RegexOption.DOT_MATCHES_ALL) + + override fun filter(text: AnnotatedString): TransformedText { + val raw = text.text + if (!raw.contains("{color:")) return TransformedText(text, OffsetMapping.Identity) + val matches = token.findAll(raw).toList() + if (matches.isEmpty()) return TransformedText(text, OffsetMapping.Identity) + + val removed = ArrayList() + val display = buildAnnotatedString { + var cursor = 0 + for (m in matches) { + val openStart = m.range.first + if (openStart > cursor) append(text.subSequence(cursor, openStart)) + val hex = m.groupValues[1] + val inner = m.groupValues[2] + val innerStart = openStart + "{color:$hex}".length + val innerEnd = innerStart + inner.length + val closeEnd = m.range.last + 1 + removed.add(openStart until innerStart) // hide "{color:#hex}" + removed.add(innerEnd until closeEnd) // hide "{/color}" + val appendStart = length + append(text.subSequence(innerStart, innerEnd)) + parseColor(hex)?.let { addStyle(SpanStyle(color = it), appendStart, length) } + cursor = closeEnd + } + if (cursor < raw.length) append(text.subSequence(cursor, raw.length)) + } + return TransformedText(display, RangeStripOffsetMapping(raw.length, removed)) + } + + private fun parseColor(hex: String): Color? = try { + Color(android.graphics.Color.parseColor(hex)) + } catch (e: IllegalArgumentException) { null } +} + +/** + * [OffsetMapping] for a display that only DELETES the given [removed] ranges from the original text + * (never inserts or reorders). Precomputes both directions so cursor/selection map correctly. + */ +private class RangeStripOffsetMapping( + originalLength: Int, + removed: List +) : OffsetMapping { + + private val origToTrans = IntArray(originalLength + 1) + private val transToOrig: IntArray + + init { + val isRemoved = BooleanArray(originalLength) + for (r in removed) for (i in r) if (i in 0 until originalLength) isRemoved[i] = true + val transList = ArrayList() + transList.add(0) + var t = 0 + for (i in 0 until originalLength) { + origToTrans[i] = t + if (!isRemoved[i]) { + t++ + transList.add(i + 1) + } + } + origToTrans[originalLength] = t + // Caret at end of the visible text maps past any trailing hidden markers. + transList[t] = originalLength + transToOrig = transList.toIntArray() + } + + override fun originalToTransformed(offset: Int): Int = + origToTrans[offset.coerceIn(0, origToTrans.size - 1)] + + override fun transformedToOriginal(offset: Int): Int = + transToOrig[offset.coerceIn(0, transToOrig.size - 1)] +} +``` + +_File: ColorFormatter.kt_ + +```kotlin lines +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.VisualTransformation +import com.cometchat.chat.models.BaseMessage +import com.cometchat.uikit.compose.presentation.shared.formatters.CometChatTextFormatter + +class ColorFormatter : CometChatTextFormatter(TRACK) { + + companion object { + // Private-use char: never typed, so this formatter never triggers suggestion tracking. + private const val TRACK = '\uE000' + private val TOKEN = + Regex("""\{color:(#[0-9a-fA-F]{6})\}(.*?)\{/color\}""", RegexOption.DOT_MATCHES_ALL) + } + + init { setDisableSuggestions(true) } + + override fun search(context: Context, queryString: String?) {} + override fun onScrollToBottom() {} + override fun getDisableSuggestions(): Boolean = true + + /** The stored token is what goes on the wire — no reverse transform needed. */ + override fun getOriginalText(text: String): String = text + + /** Live WYSIWYG rendering in the composer field: hide markers, colour the inner text. */ + override fun composerVisualTransformation(): VisualTransformation = + ColorTokenVisualTransformation() + + private fun parseColor(hex: String): Color? = try { + Color(android.graphics.Color.parseColor(hex)) + } catch (e: IllegalArgumentException) { null } + + /** + * Rebuilds [text] with every `{color:#…}…{/color}` token replaced by its inner content styled + * with the colour, preserving any spans an earlier formatter (e.g. mentions) already applied. + */ + private fun render(text: AnnotatedString): AnnotatedString { + val raw = text.text + if (!raw.contains("{color:")) return text + val matches = TOKEN.findAll(raw).toList() + if (matches.isEmpty()) return text + return buildAnnotatedString { + var cursor = 0 + for (m in matches) { + val openStart = m.range.first + if (openStart > cursor) append(text.subSequence(cursor, openStart)) + val hex = m.groupValues[1] + val inner = m.groupValues[2] + val innerStart = openStart + "{color:$hex}".length + val innerEnd = innerStart + inner.length + val appendStart = length + append(text.subSequence(innerStart, innerEnd)) + parseColor(hex)?.let { addStyle(SpanStyle(color = it), appendStart, length) } + cursor = m.range.last + 1 + } + if (cursor < raw.length) append(text.subSequence(cursor, raw.length)) + } + } + + // Composer span is IDENTITY: the live field renders colour via composerVisualTransformation(), + // and edit-populate reads this text back into the input — so the token must be kept intact here. + override fun prepareComposerSpan( + context: Context, baseMessage: BaseMessage, text: AnnotatedString + ) = text + + override fun prepareLeftMessageBubbleSpan( + context: Context, baseMessage: BaseMessage, text: AnnotatedString + ) = render(text) + + override fun prepareRightMessageBubbleSpan( + context: Context, baseMessage: BaseMessage, text: AnnotatedString + ) = render(text) + + override fun prepareConversationSpan( + context: Context, baseMessage: BaseMessage, text: AnnotatedString + ) = render(text) + + // Preview panels are display surfaces → strip markers + colour (unlike the identity composer span). + override fun preparePreviewSpan( + context: Context, baseMessage: BaseMessage, text: AnnotatedString + ) = render(text) +} +``` + + + + +## Step 2: The Toolbar Button + +The trailing slot hands your view a live [`ComposerInputController`](/ui-kit/android/message-composer#rich-text-toolbar-trailing-buttons). The button reads `selection`, takes the selected substring out of `text`, and writes the token back with `replaceSelection()`. With nothing selected, it inserts a colored placeholder word, `text`, at the caret for the user to type over. Use six-digit hex values (`#RRGGBB`) — Android's `Color.parseColor` does not accept the three-digit shorthand. + + + + +_File: ColorToolbarButton.kt_ + +```kotlin lines +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.view.Gravity +import android.view.View +import android.widget.PopupMenu +import android.widget.TextView +import com.cometchat.uikit.core.formatter.ComposerInputController + +private val COLORS = listOf( + "Red" to "#E53935", + "Green" to "#43A047", + "Blue" to "#1E88E5", + "Orange" to "#FB8C00", +) + +/** A small "A" button that opens a colour palette and wraps the current selection in a token. */ +fun createColorButton(context: Context, input: ComposerInputController): View = + TextView(context).apply { + text = "A" + setTypeface(typeface, Typeface.BOLD) + textSize = 16f + setTextColor(Color.parseColor("#1E88E5")) + gravity = Gravity.CENTER + val pad = (12 * resources.displayMetrics.density).toInt() + setPadding(pad, 0, pad, 0) + contentDescription = "Text color" + setOnClickListener { anchor -> + PopupMenu(context, anchor).apply { + COLORS.forEachIndexed { i, (name, _) -> menu.add(0, i, i, name) } + setOnMenuItemClickListener { item -> + applyColor(input, COLORS[item.itemId].second) + true + } + }.show() + } + } + +private val URL_REGEX = Regex("""(https?://|www\.)\S+""", RegexOption.IGNORE_CASE) + +/** + * Applies the colour token to the selection — but NOT when the selection contains a link or a + * mention. Those carry their own styling, and colouring them would mean deleting/re-inserting the + * selected text, which strips a mention's underlying NonEditableSpan (it would silently become + * plain text). So a selection that touches a link/mention is left untouched. + */ +private fun applyColor(input: ComposerInputController, hex: String) { + val len = input.text.length + val sel = input.selection + val start = minOf(sel.first, sel.last).coerceIn(0, len) + val end = maxOf(sel.first, sel.last).coerceIn(0, len) + if (end <= start) { + input.insertAtCursor("{color:$hex}text{/color}") + return + } + val selected = input.text.substring(start, end) + val overlapsMention = input.mentionRanges().any { it.first < end && it.last + 1 > start } + if (overlapsMention || URL_REGEX.containsMatchIn(selected)) return + input.replaceSelection("{color:$hex}$selected{/color}") +} +``` + + + + +_File: ColorToolbarButton.kt_ + +```kotlin lines +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.cometchat.uikit.core.formatter.ComposerInputController + +private val COLORS = listOf( + "Red" to "#E53935", + "Green" to "#43A047", + "Blue" to "#1E88E5", + "Orange" to "#FB8C00", +) + +/** Opens a small colour palette and wraps the current composer selection in a token. */ +@Composable +fun RowScope.ColorToolbarButton(input: ComposerInputController) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Text("A", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = Color(0xFF1E88E5)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + COLORS.forEach { (name, hex) -> + DropdownMenuItem( + text = { Text(name, color = Color(android.graphics.Color.parseColor(hex))) }, + onClick = { + applyColor(input, hex) + expanded = false + } + ) + } + } + } +} + +private val URL_REGEX = Regex("""(https?://|www\.)\S+""", RegexOption.IGNORE_CASE) + +/** + * Applies the colour token to the selection — but NOT when the selection contains a link or a + * mention. Those carry their own styling, and colouring them would mean deleting/re-inserting the + * selected text, which strips a mention's underlying tracking span (it would silently become plain + * text). So a selection that touches a link/mention is left untouched. + */ +private fun applyColor(input: ComposerInputController, hex: String) { + val len = input.text.length + val sel = input.selection + val start = minOf(sel.first, sel.last).coerceIn(0, len) + val end = maxOf(sel.first, sel.last).coerceIn(0, len) + if (end <= start) { + input.insertAtCursor("{color:$hex}text{/color}") + return + } + val selected = input.text.substring(start, end) + val overlapsMention = input.mentionRanges().any { it.first < end && it.last + 1 > start } + if (overlapsMention || URL_REGEX.containsMatchIn(selected)) return + input.replaceSelection("{color:$hex}$selected{/color}") +} +``` + + + + + +A `PopupMenu` / `DropdownMenu` is safe here. The composer keeps its selection while the menu is open, so the selection is still there when your handler runs. + + +## Step 3: Wire It Into the Composer + +Register the formatter on the composer and mount the button in the trailing slot. + + + + +```kotlin lines +import android.content.Context +import android.view.View +import com.cometchat.chat.models.Group +import com.cometchat.chat.models.User +import com.cometchat.uikit.core.formatter.ComposerInputController +import com.cometchat.uikit.kotlin.presentation.messagecomposer.utils.RichTextToolbarTrailingViewListener +import com.cometchat.uikit.kotlin.shared.formatters.CometChatMentionsFormatter + +messageComposer.setTextFormatters( + listOf(CometChatMentionsFormatter(this), ColorFormatter()) +) + +messageComposer.setRichTextToolbarTrailingViewListener( + object : RichTextToolbarTrailingViewListener { + override fun createView( + context: Context, + user: User?, + group: Group?, + input: ComposerInputController + ): View = createColorButton(context, input) + } +) +``` + + + + +```kotlin lines +import androidx.compose.ui.platform.LocalContext +import com.cometchat.uikit.compose.presentation.shared.formatters.CometChatMentionsFormatter + +val context = LocalContext.current +val composerFormatters = remember(context) { + listOf(CometChatMentionsFormatter(context), ColorFormatter()) +} + +CometChatMessageComposer( + user = user, + enableRichTextFormatting = true, + textFormatters = composerFormatters, + trailingToolbarContent = { input -> ColorToolbarButton(input) } +) +``` + + + + +Now: the user selects "world", picks Red, and the word turns red in the composer while the `{color:…}` markers stay hidden. On send, the message text `Hello {color:#E53935}world{/color}` is stored on the message. + +## Step 4: Render It Everywhere the Message Appears + +The token only becomes color on a surface that runs the formatter. Register it on every component where the message can show up. + + +Give each surface its **own formatter instances**. The built-in mentions formatter is stateful per rendered message, so sharing one list between the composer and the message list will cross-wire them. + + + + + +```kotlin lines +messageList.setTextFormatters(listOf(CometChatMentionsFormatter(this), ColorFormatter())) +conversations.setTextFormatters(listOf(CometChatMentionsFormatter(this), ColorFormatter())) +pinnedMessages.setTextFormatters(listOf(CometChatMentionsFormatter(this), ColorFormatter())) +savedMessages.setTextFormatters(listOf(CometChatMentionsFormatter(this), ColorFormatter())) +``` + + + + +```kotlin lines +// One list per surface — each gets its own instances +fun formatters() = listOf(CometChatMentionsFormatter(context), ColorFormatter()) + +CometChatMessageList(user = user, textFormatters = remember(context) { formatters() }) +CometChatConversations(textFormatters = remember(context) { formatters() }) +CometChatPinnedMessages(user = user, textFormatters = remember(context) { formatters() }) +CometChatSavedMessages(textFormatters = remember(context) { formatters() }) +``` + + + + + +The token is plain text, so every client that shows these messages has to render it. If your users also chat from other platforms, use the same `{color:#hex}…{/color}` token there, or they will see it as raw text. + + +## How It Round-Trips + +| Stage | What the text is | What the user sees | +| --- | --- | --- | +| Composer, after picking a color | `Hello {color:#E53935}world{/color}` | `Hello world`, with "world" red and the markers hidden | +| On the wire | `Hello {color:#E53935}world{/color}` | — | +| Message bubble | `Hello {color:#E53935}world{/color}` | `Hello world`, with "world" red | + +The markers never leave the message text — the composer only hides them, and the bubble's `prepare*Span()` strips them at render time. That is what lets the color survive edit and reply: the composer repopulates with the stored token and renders it again. + +## Next Steps + + + + The trailing-toolbar slot in detail + + + Tracking characters, suggestion lists, and pre-send hooks + + + Built-in @mention formatting with styled tokens + + + Browse all feature and formatter guides + + diff --git a/ui-kit/android/guide-thread-subscription.mdx b/ui-kit/android/guide-thread-subscription.mdx deleted file mode 100644 index 62f47b73f..000000000 --- a/ui-kit/android/guide-thread-subscription.mdx +++ /dev/null @@ -1,157 +0,0 @@ ---- -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 b827dbcba..c5c2addcd 100644 --- a/ui-kit/android/guide-threaded-messages.mdx +++ b/ui-kit/android/guide-threaded-messages.mdx @@ -326,6 +326,126 @@ if (user.isBlockedByMe) { | Blocked User | Composer hidden; unblock layout shown. | | Not in Group | Show option to join group first. | +## Thread Subscription + + +Thread subscription is **enabled by default from v6.1.0**. On **v6.0.x** it is opt-in: nothing on this page renders until you pass `UIKitSettings.setEnableThreadSubscription(true)` to `CometChatUIKit.init()`. That setter is deprecated in v6.1.0 but still works, and is the only app-wide way to turn the feature off — see [setEnableThreadSubscription](/ui-kit/android/methods#init). + + +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. The **server** also subscribes users automatically when they send a message (to that message's own thread), reply in a thread, or are @-mentioned in a reply — these happen server-side, so they apply to any app on the SDK, and the UI Kit reflects them as soon as they occur. Subscribing explicitly is how users opt in to a conversation they haven't participated in yet. + +The UI Kit ships two surfaces for the same toggle, wired out of the box and 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. + +### The Message Action Sheet Option + +[CometChatMessageList](/ui-kit/android/message-list) 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) +``` + + +```kotlin lines +CometChatMessageList( + user = user, + hideThreadSubscriptionOption = true +) +``` + + + +### 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 (including the CometChat sample apps) 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 = { + 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 + } +}) +``` + + + +### Subscription 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). + ## Summary / Feature Matrix | Feature | Component / Method | @@ -336,12 +456,13 @@ if (user.isBlockedByMe) { | Compose reply | `composer.setParentMessageId(...)` **+** `setUser(...)`/`setGroup(...)` | | Scope the thread (Compose)| Own `NavHost` destination — a shared composition scope reuses the main chat's view model | | Handle blocked users | `isBlockedByMe()`, hide composer + show unblock UI | +| Subscribe / unsubscribe | Action-sheet option + `CometChatThreadHeader` bell | ## Next Steps & Further Reading - - Let users subscribe to or unsubscribe from a thread to control whether its replies notify them. + + The underlying APIs, including fetching the threads a user participates in to build a thread inbox. Explore this feature in the CometChat SampleApp: diff --git a/ui-kit/android/llms-android-v6.mdx b/ui-kit/android/llms-android-v6.mdx index 0bf9a8592..9716f8355 100644 --- a/ui-kit/android/llms-android-v6.mdx +++ b/ui-kit/android/llms-android-v6.mdx @@ -124,7 +124,7 @@ attributes, long-tail components, theming tokens, or feature enablement. Pending pages — on branch feature/android-pin_save_thread, not yet on main. Uncomment once that branch merges: Components: [Pinned Messages](/ui-kit/android/pinned-messages) · [Saved Messages](/ui-kit/android/saved-messages) - Task guides: [Thread Subscription](/ui-kit/android/guide-thread-subscription) · [Pin & Save Messages](/ui-kit/android/guide-pin-and-save-messages) + Task guides: [Thread Subscription](/ui-kit/android/guide-threaded-messages#thread-subscription) · [Pin & Save Messages](/ui-kit/android/guide-pin-and-save-messages) */} ## Something is broken — symptom to page diff --git a/ui-kit/android/message-list.mdx b/ui-kit/android/message-list.mdx index d5dbbe5a0..6b02412e5 100644 --- a/ui-kit/android/message-list.mdx +++ b/ui-kit/android/message-list.mdx @@ -827,13 +827,14 @@ messageList.setMarkAsUnreadOptionVisibility(View.VISIBLE) ```kotlin lines CometChatMessageList( user = user, - hideDeleteOption = true, - hideEditOption = true, - hideTranslateOption = true, - showMarkAsUnreadOption = true + hideDeleteMessageOption = true, + hideEditMessageOption = true, + hideTranslateMessageOption = true ) ``` +In Compose, **Mark as unread** is shown by default; hide it with `hideMarkAsUnreadOption = true`. + @@ -851,7 +852,9 @@ 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) | +| `setThreadSubscriptionOptionVisibility()` | `VISIBLE`* | Subscribe / Unsubscribe thread option (*from v6.1.0; on v6.0.x the option renders only after `setEnableThreadSubscription(true)`) | + +In Compose, hide the thread subscription option with `hideThreadSubscriptionOption = true`. ### Feature Options (Pin, Save, Thread Subscription) @@ -859,9 +862,9 @@ Three groups of options appear automatically when their feature is enabled for t - **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)`. +- **Subscribe to thread / Unsubscribe from thread** — shown on regular messages (not agent or moderation-blocked ones) from **v6.1.0**, where thread subscription is on by default; on **v6.0.x** it appears only after `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. +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) guide and [Threaded Messages → Thread Subscription](/ui-kit/android/guide-threaded-messages#thread-subscription). ### Replacing All Options (`setOptions`) diff --git a/ui-kit/android/methods.mdx b/ui-kit/android/methods.mdx index 963704528..e4e53101e 100644 --- a/ui-kit/android/methods.mdx +++ b/ui-kit/android/methods.mdx @@ -74,7 +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) | +| **setEnableThreadSubscription** | `Boolean` | *Deprecated in v6.1.0.* Global switch for the thread subscription feature. Default `true` from v6.1.0 (`false` on v6.0.x, where it is the opt-in). Kept as the only app-wide opt-out — pass `false` to turn the feature off everywhere. To hide a single surface, prefer the per-screen visibility methods. See [Thread Subscription](/ui-kit/android/guide-threaded-messages#thread-subscription) | **Usage:** @@ -427,7 +427,7 @@ Synchronous, UI-safe checks for whether a feature is available. Use them to gate | `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)`. | +| `CometChatUIKit.isThreadSubscriptionEnabled()` | Whether thread subscription is enabled for the app. `true` by default from v6.1.0; on v6.0.x it reports whether `UIKitSettings.setEnableThreadSubscription(true)` was passed. | ```kotlin if (CometChatUIKit.isPinMessageEnabled()) { diff --git a/ui-kit/android/pinned-messages.mdx b/ui-kit/android/pinned-messages.mdx index 272d7bce7..5da9350ab 100644 --- a/ui-kit/android/pinned-messages.mdx +++ b/ui-kit/android/pinned-messages.mdx @@ -39,17 +39,24 @@ description: "Full-screen list of all messages pinned in a conversation, with ju -## Where It Fits +`CometChatPinnedMessages` lists the messages pinned in one conversation, newest pin first. A pin is conversation-wide and visible to everyone, so this screen shows the same set to every participant. -`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. + +**Pin Messages must be enabled for your app in the CometChat Dashboard.** Until it is, the pin options never render and this screen has nothing to show. Read the flag with `CometChatUIKit.isPinMessageEnabled()` to gate your own entry point. + -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). +## Where It Fits - +Open it from your conversation screen, scoped to the conversation the user is in — the [Message Header](/ui-kit/android/message-header) provides a built-in "Pinned messages" menu item for this. It is a full-screen panel with its own back affordance rather than an inline strip. -Pinned messages require the **Pin Message** feature to be enabled for your app. Gate your entry point with `CometChatUIKit.isPinMessageEnabled()`. +Each row renders the real message bubble, and a long-press menu offers Message info, Copy, Translate (text messages), Unpin and Delete. There is no Subscribe/Unsubscribe to thread option here: a pin that arrives in real time doesn't carry its thread's subscription state, so the panel can't label the toggle correctly. Messages themselves are pinned and unpinned from the [Message List](/ui-kit/android/message-list) action sheet; wire `setOnMessageClickListener` to jump back to a message in context. - + + The Pinned Messages screen: a titled screen with a back arrow, listing the pinned messages of one conversation as full bubbles grouped by sender and date. Each bubble carries a pin glyph beside its timestamp, and a media message shows its image with the caption beneath. + ## Quick Start @@ -132,7 +139,7 @@ The list keeps itself up to date — when the logged-in user pins or unpins a me ## 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. +- **Long-press menu** — long-press a row for message actions: **Message info**, **Copy**, **Translate** (text messages), **Unpin**, 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. diff --git a/ui-kit/android/saved-messages.mdx b/ui-kit/android/saved-messages.mdx index 949b30557..6ca003d08 100644 --- a/ui-kit/android/saved-messages.mdx +++ b/ui-kit/android/saved-messages.mdx @@ -34,17 +34,24 @@ description: "Full-screen, private list of every message the logged-in user has -## Where It Fits +`CometChatSavedMessages` lists every message the logged-in user has saved, newest save first. Unlike a pin, a save is **private and spans conversations** — nobody else can see it, and the list is not scoped to a single chat. -`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. + +**Save Messages must be enabled for your app in the CometChat Dashboard.** Until it is, the Save option never renders and this screen has nothing to show. Read the flag with `CometChatUIKit.isSaveMessageEnabled()` to gate your own entry point. + -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. +## Where It Fits - +Because the list is account-wide, this screen belongs at app level — a navigation tab, a drawer entry, or a profile menu — not inside a single conversation. There is deliberately no `setUser`/`setGroup`; scoping it to one conversation would defeat the point. -Saved messages require the **Save Message** feature to be enabled for your app. Gate your entry point with `CometChatUIKit.isSaveMessageEnabled()`. +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. - + + The Saved Messages screen: a titled screen with a back arrow, listing the user's saved messages from across every conversation as conversation-style rows. Each row shows the source conversation's avatar and name, a one-line preview with a type glyph for media, documents, stickers and contacts, and the time it was saved. + ## Quick Start diff --git a/ui-kit/android/threaded-messages-header.mdx b/ui-kit/android/threaded-messages-header.mdx index cc9db9a38..715e74199 100644 --- a/ui-kit/android/threaded-messages-header.mdx +++ b/ui-kit/android/threaded-messages-header.mdx @@ -93,22 +93,51 @@ 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`. The one interactive element is the **subscription bell** (below), which reports state changes through its own callback. +`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** — see [Thread Subscription](#thread-subscription) below. -#### Subscription Bell (`onThreadSubscriptionChange` / `onSubscriptionToggle`) +### SDK Events (Real-Time, Automatic) + +The component listens to SDK events internally via its ViewModel. No manual setup needed. + +| SDK Listener | Internal behavior | +| --- | --- | +| Message edited | Updates the parent message bubble | +| Message deleted | Updates the parent message bubble | +| Reply count changed | Updates the reply count indicator | + +--- + +## Functionality + +| Method (Kotlin XML) | Compose Parameter | Description | +| --- | --- | --- | +| `setParentMessage(message)` | `parentMessage = message` | Set the parent message (required) | +| `setUser(user)` | `user = user` | Set the user context | +| `setGroup(group)` | `group = group` | Set the group context | +| `setReactionVisibility(View.GONE)` | `hideReactions = true` | Toggle reactions on parent bubble | +| `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 | +| `setOnThreadSubscriptionChange { }` | `onSubscriptionToggle = { }` | Subscription-state change callback | +| — | `threadSubscriptionView = { }` | Replace the bell with a custom composable | + +--- + +## Thread Subscription -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. +On Android the subscription bell belongs to `CometChatThreadHeader` — it renders as a trailing control on this component's reply-count row. Thread subscription is enabled by default from **v6.1.0**; on **v6.0.x** the bell renders only after `UIKitSettings.setEnableThreadSubscription(true)`. It flips optimistically on tap, reverts with a toast if the server rejects the change, and stays in step with the message list's Subscribe / Unsubscribe action sheet option. ```kotlin lines -// Observe state changes +// Observe state changes (isSubscribed = the new state) 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) +// Hide the bell on this screen threadHeader.setThreadSubscriptionVisibility(View.GONE) ``` @@ -127,37 +156,26 @@ CometChatThreadHeader( ) ``` -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) - -The component listens to SDK events internally via its ViewModel. No manual setup needed. +To place the bell in the thread screen's **top title bar** instead (as the CometChat sample apps do), Compose exposes it standalone — hide this component's built-in bell and host `ThreadSubscriptionBell` in your own top bar: -| SDK Listener | Internal behavior | -| --- | --- | -| Message edited | Updates the parent message bubble | -| Message deleted | Updates the parent message bubble | -| Reply count changed | Updates the reply count indicator | +```kotlin lines +TopAppBar( + title = { Text(stringResource(R.string.thread)) }, + actions = { ThreadSubscriptionBell(parentMessage = parentMessage) } +) ---- +CometChatThreadHeader( + parentMessage = parentMessage, + hideThreadSubscription = true // the bell lives in the top bar instead +) +``` -## Functionality +This component's own `threadSubscriptionView` replaces the bell in the reply-count row. It is the escape hatch for putting your own control there — it does not move the bell to the top bar. -| Method (Kotlin XML) | Compose Parameter | Description | -| --- | --- | --- | -| `setParentMessage(message)` | `parentMessage = message` | Set the parent message (required) | -| `setUser(user)` | `user = user` | Set the user context | -| `setGroup(group)` | `group = group` | Set the group context | -| `setReactionVisibility(View.GONE)` | `hideReactions = true` | Toggle reactions on parent bubble | -| `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 | +The same subscribe/unsubscribe action is also offered as a message option on [CometChatMessageList](/ui-kit/android/message-list), hidden independently with `setThreadSubscriptionOptionVisibility(View.GONE)` — an integrator may want one surface and not the other. See the [Thread Subscription guide](/ui-kit/android/guide-threaded-messages#thread-subscription) for the full feature. ---