diff --git a/docs.json b/docs.json index a525c7fae..686ee9a0b 100644 --- a/docs.json +++ b/docs.json @@ -1856,13 +1856,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", @@ -6939,6 +6939,10 @@ } }, "redirects": [ + { + "source": "/ui-kit/android/guide-thread-subscription", + "destination": "/ui-kit/android/guide-threaded-messages#thread-subscription" + }, { "source": "/sdk/flutter/group-kick-member", "destination": "/sdk/flutter/group-kick-ban-members" 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..09d856554 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,40 @@ 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 needs a server flag that is not seeded.** It requires `features.ux.conversations.pinned.enabled`, which ships enabled in no plan and must be mapped per app. Until then the option never renders, and a direct SDK call rejects with `ERR_FEATURE_NOT_ACCESSIBLE`. The UI Kit reads that flag 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)` 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) +``` + +### Reading pin state + +```kotlin lines +val isPinned = conversation.isPinned // pinnedAt > 0 + +// An admin/global pin. A user cannot unpin one of these, +// so hide or disable the 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 | @@ -709,18 +752,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..1291e3e74 100644 --- a/ui-kit/android/core-features.mdx +++ b/ui-kit/android/core-features.mdx @@ -169,6 +169,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 +184,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 — remove a surface with `setThreadSubscriptionOptionVisibility(View.GONE)` or `setThreadSubscriptionVisibility(View.GONE)`. | 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. +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 89c7b5070..6054f706b 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 a4a18ea62..a08ad343d 100644 --- a/ui-kit/android/events.mdx +++ b/ui-kit/android/events.mdx @@ -190,7 +190,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 dd8297b78..62db7f5ba 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. | +| [Custom Text Formatter](/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-text-color.mdx b/ui-kit/android/guide-text-color.mdx new file mode 100644 index 000000000..c3f3f1deb --- /dev/null +++ b/ui-kit/android/guide-text-color.mdx @@ -0,0 +1,661 @@ +--- +title: "Custom Text Formatter" +sidebarTitle: "Custom Text Formatter" +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]{3,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]{3,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]{3,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()`. + + + + +_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. An Android `EditText` keeps its `selectionStart` and `selectionEnd` when a popup takes focus, 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, + 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 +val listFormatters = remember(context) { + listOf(CometChatMentionsFormatter(context), ColorFormatter()) +} + +CometChatMessageList(user = user, textFormatters = listFormatters) +``` + + + + +## 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 900ed3bd7..b2e60268e 100644 --- a/ui-kit/android/guide-threaded-messages.mdx +++ b/ui-kit/android/guide-threaded-messages.mdx @@ -274,6 +274,114 @@ if (user.isBlockedByMe) { | Blocked User | Composer hidden; unblock layout shown. | | Not in Group | Show option to join group first. | +## Thread Subscription + +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, 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) +``` + + + +### 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 = { + 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 | @@ -283,12 +391,13 @@ if (user.isBlockedByMe) { | Show parent message | `header.setParentMessage(parentMessage)` | | Compose reply | `composer.setParentMessageId(parentMessage.getId())` | | 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/message-list.mdx b/ui-kit/android/message-list.mdx index 723f3ad1f..b567a3f08 100644 --- a/ui-kit/android/message-list.mdx +++ b/ui-kit/android/message-list.mdx @@ -850,7 +850,7 @@ 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 | ### Feature Options (Pin, Save, Thread Subscription) @@ -858,9 +858,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). 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..f8da9c17f 100644 --- a/ui-kit/android/methods.mdx +++ b/ui-kit/android/methods.mdx @@ -74,7 +74,6 @@ 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:** @@ -427,7 +426,6 @@ 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)`. | ```kotlin if (CometChatUIKit.isPinMessageEnabled()) { diff --git a/ui-kit/android/pinned-messages.mdx b/ui-kit/android/pinned-messages.mdx index e07249749..5fbda2ddd 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, Unpin, Subscribe/Unsubscribe to thread and Delete. 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 diff --git a/ui-kit/android/saved-messages.mdx b/ui-kit/android/saved-messages.mdx index 4e7cf70d0..b093ab3ca 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..f0d923443 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. 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. +The landed design places the bell in the thread screen's **top title bar** rather than the reply-count row. Compose exposes it standalone for exactly that — 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. --- diff --git a/ui-kit/react/core-features.mdx b/ui-kit/react/core-features.mdx index e9d56d92a..afec81f80 100644 --- a/ui-kit/react/core-features.mdx +++ b/ui-kit/react/core-features.mdx @@ -152,6 +152,17 @@ The Threaded Conversations feature enables users to respond directly to a specif | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Threaded Message Preview](/ui-kit/react/guide-threaded-messages) | [Threaded Message Preview](/ui-kit/react/guide-threaded-messages) component displays the parent message along with the number of replies. | +### Thread Subscription + +Let users subscribe to or unsubscribe from a thread to control whether its replies notify them. Enabled by default — remove a surface with `hideThreadSubscriptionToggle` or `hideThreadSubscriptionOption`. + +| Component | Role | +| --- | --- | +| [Message List](/ui-kit/react/components/message-list#hidethreadsubscriptionoption) | Provides the Subscribe to thread / Unsubscribe from thread option in the message context menu. | +| [Thread Header](/ui-kit/react/components/thread-header#thread-subscription) | Shows the subscription bell on the thread view. | + +See [Threaded Messages → Thread Subscription](/ui-kit/react/guide-threaded-messages#thread-subscription) for setup and behavior. + ## Quoted Replies Quoted Replies is a robust feature provided by CometChat that enables users to quickly reply to specific messages by selecting the "Reply" option from a message's action menu. This enhances context, keeps conversations organized, and improves overall chat experience in both 1-1 and group chats.