diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7352f655d4c..94e591237ad 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -62,6 +62,7 @@ ksp = "2.0.21-1.0.28"
landscapist = "2.4.7"
leakCanary = "2.4"
macroBenchmark = "1.2.3"
+markdown = "0.7.3"
markwon = "4.6.2"
materialComponents = "1.12.0"
mockitoKotlin = "5.4.0"
@@ -182,6 +183,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines"}
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin"}
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakCanary"}
+markdown = { module = "org.jetbrains:markdown", version.ref = "markdown"}
markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon"}
markwon-ext-strikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon"}
markwon-linkify = { module = "io.noties.markwon:linkify", version.ref = "markwon"}
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt
index deb0a84a3f6..f3435aa5f3c 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt
@@ -32,8 +32,13 @@ class CustomSettings(private val context: Context) {
get() = prefs.getBoolean(SETTINGS_KEY_ADAPTIVE_LAYOUT, false)
set(value) = prefs.edit().putBoolean(SETTINGS_KEY_ADAPTIVE_LAYOUT, value).apply()
+ var isMarkdownEnabled: Boolean
+ get() = prefs.getBoolean(SETTINGS_KEY_MARKDOWN, false)
+ set(value) = prefs.edit().putBoolean(SETTINGS_KEY_MARKDOWN, value).apply()
+
companion object {
private const val SETTINGS_KEY_ADAPTIVE_LAYOUT = "adaptive_layout"
+ private const val SETTINGS_KEY_MARKDOWN = "markdown"
}
}
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt
index b5866259a47..29de3336e3c 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt
@@ -60,6 +60,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import io.getstream.chat.android.compose.sample.ChatApp
import io.getstream.chat.android.compose.sample.R
+import io.getstream.chat.android.compose.sample.data.customSettings
import io.getstream.chat.android.compose.sample.feature.channel.isGroupChannel
import io.getstream.chat.android.compose.sample.ui.channel.DirectChannelInfoActivity
import io.getstream.chat.android.compose.sample.ui.channel.GroupChannelInfoActivity
@@ -89,6 +90,7 @@ import io.getstream.chat.android.compose.ui.theme.ReactionOptionsTheme
import io.getstream.chat.android.compose.ui.theme.StreamColors
import io.getstream.chat.android.compose.ui.theme.StreamShapes
import io.getstream.chat.android.compose.ui.theme.StreamTypography
+import io.getstream.chat.android.compose.ui.util.MessageTextFormatter
import io.getstream.chat.android.compose.ui.util.rememberMessageListState
import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel
import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel
@@ -141,6 +143,29 @@ class MessagesActivity : ComponentActivity() {
}
}
+ @Composable
+ private fun messageTextFormatter(
+ isInDarkMode: Boolean,
+ typography: StreamTypography,
+ shapes: StreamShapes,
+ colors: StreamColors,
+ ): MessageTextFormatter = when {
+ customSettings().isMarkdownEnabled -> MessageTextFormatter.markdownFormatter(
+ autoTranslationEnabled = ChatApp.autoTranslationEnabled,
+ isInDarkMode = isInDarkMode,
+ typography = typography,
+ colors = colors,
+ )
+
+ else -> MessageTextFormatter.defaultFormatter(
+ autoTranslationEnabled = ChatApp.autoTranslationEnabled,
+ isInDarkMode = isInDarkMode,
+ typography = typography,
+ shapes = shapes,
+ colors = colors,
+ )
+ }
+
@Composable
private fun SetupChatTheme() {
val isInDarkMode = isSystemInDarkTheme()
@@ -162,6 +187,7 @@ class MessagesActivity : ComponentActivity() {
colors = colors,
shapes = shapes,
typography = typography,
+ messageTextFormatter = messageTextFormatter(isInDarkMode, typography, shapes, colors),
attachmentsPickerTabFactories = attachmentsPickerTabFactories,
componentFactory = CustomChatComponentFactory(),
dateFormatter = ChatApp.dateFormatter,
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt
index cc4f4b9d8f2..3bde2a0bd65 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt
@@ -21,6 +21,7 @@ import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
+import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -125,6 +126,7 @@ class CustomLoginActivity : AppCompatActivity() {
var userTokenText by remember { mutableStateOf("") }
var userNameText by remember { mutableStateOf("") }
var isAdaptiveLayoutEnabled by remember { mutableStateOf(settings.isAdaptiveLayoutEnabled) }
+ var isMarkdownEnabled by remember { mutableStateOf(settings.isMarkdownEnabled) }
val isLoginButtonEnabled = apiKeyText.isNotEmpty() &&
userIdText.isNotEmpty() &&
@@ -134,6 +136,10 @@ class CustomLoginActivity : AppCompatActivity() {
settings.isAdaptiveLayoutEnabled = isAdaptiveLayoutEnabled
}
+ LaunchedEffect(isMarkdownEnabled) {
+ settings.isMarkdownEnabled = isMarkdownEnabled
+ }
+
CustomLoginInputField(
hint = stringResource(id = R.string.custom_login_hint_api_key),
value = apiKeyText,
@@ -160,11 +166,20 @@ class CustomLoginActivity : AppCompatActivity() {
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
- EnableAdaptiveScreenField(
+ FeatureFlagField(
+ title = R.string.custom_login_enable_adaptive_layout,
+ description = R.string.custom_login_enable_adaptive_layout_description,
value = isAdaptiveLayoutEnabled,
onValueChange = { isChecked -> isAdaptiveLayoutEnabled = isChecked },
)
+ FeatureFlagField(
+ title = R.string.custom_login_enable_markdown,
+ description = R.string.custom_login_enable_markdown_description,
+ value = isMarkdownEnabled,
+ onValueChange = { isChecked -> isMarkdownEnabled = isChecked },
+ )
+
Spacer(modifier = Modifier.weight(1f))
CustomLoginButton(
@@ -272,7 +287,9 @@ class CustomLoginActivity : AppCompatActivity() {
}
@Composable
- private fun EnableAdaptiveScreenField(
+ private fun FeatureFlagField(
+ @StringRes title: Int,
+ @StringRes description: Int,
value: Boolean,
onValueChange: (Boolean) -> Unit,
) {
@@ -293,11 +310,11 @@ class CustomLoginActivity : AppCompatActivity() {
)
Column {
Text(
- text = stringResource(id = R.string.custom_login_enable_adaptive_layout),
+ text = stringResource(id = title),
style = ChatTheme.typography.title3,
)
Text(
- text = stringResource(id = R.string.custom_login_enable_adaptive_layout_description),
+ text = stringResource(id = description),
style = ChatTheme.typography.footnote,
)
}
diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml
index 91f605fab48..bfbbf8b966d 100644
--- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml
+++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml
@@ -35,6 +35,8 @@
Username (optional)
Enable adaptive layout (Experimental)
Adjust layout based on screen sizes
+ Enable markdown
+ Render message text as markdown
Pinned Messages
diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api
index a10b9110604..13e7b34fe8e 100644
--- a/stream-chat-android-compose/api/stream-chat-android-compose.api
+++ b/stream-chat-android-compose/api/stream-chat-android-compose.api
@@ -4648,6 +4648,7 @@ public final class io/getstream/chat/android/compose/ui/util/MessageTextFormatte
public final fun composite ([Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;)Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;
public final fun defaultFormatter (ZZLio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;
public final fun defaultFormatter (ZZLio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;
+ public final fun markdownFormatter (ZZLio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;
}
public final class io/getstream/chat/android/compose/ui/util/MessageUtilsKt {
diff --git a/stream-chat-android-compose/build.gradle.kts b/stream-chat-android-compose/build.gradle.kts
index 45baf3beed7..848181a93dd 100644
--- a/stream-chat-android-compose/build.gradle.kts
+++ b/stream-chat-android-compose/build.gradle.kts
@@ -95,6 +95,9 @@ dependencies {
implementation(libs.coil.gif)
implementation(libs.coil.video)
+ // Markdown
+ implementation(libs.markdown)
+
// Media3
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt
index 43eaaeb7bcd..41442b06025 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt
@@ -16,6 +16,7 @@
package io.getstream.chat.android.compose.ui.components.messages
+import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.gestures.detectTapGestures
@@ -42,6 +43,8 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail
import io.getstream.chat.android.compose.ui.util.AnnotationTagMention
import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl
+import io.getstream.chat.android.compose.ui.util.MarkdownStyles
+import io.getstream.chat.android.compose.ui.util.blockQuoteRails
import io.getstream.chat.android.compose.ui.util.isEmojiOnlyWithoutBubble
import io.getstream.chat.android.compose.ui.util.isFewEmoji
import io.getstream.chat.android.compose.ui.util.isSingleEmoji
@@ -89,7 +92,16 @@ public fun MessageText(
}
}
- val annotations = styledText.getStringAnnotations(0, styledText.lastIndex)
+ val annotations = styledText.getStringAnnotations(0, styledText.length)
+
+ // Read inside the draw pass, which runs after the layout that sets it.
+ val layout = remember(styledText) { mutableStateOf(null) }
+ val quoteRails = Modifier.blockQuoteRails(
+ annotations = annotations,
+ layout = layout::value,
+ color = ChatTheme.colors.textLowEmphasis,
+ indentPerDepth = MarkdownStyles.BlockQuoteIndent,
+ )
// TODO: Fix emoji font padding once this is resolved and exposed: https://issuetracker.google.com/issues/171394808
val style = when {
@@ -101,10 +113,7 @@ public fun MessageText(
ChatTheme.otherMessageTheme.textStyle
}
}
- if (annotations.fastAny {
- it.tag == AnnotationTagUrl || it.tag == AnnotationTagEmail || it.tag == AnnotationTagMention
- }
- ) {
+ if (annotations.fastAny(AnnotatedString.Range::isClickableTag)) {
ClickableText(
modifier = modifier
.padding(
@@ -113,21 +122,30 @@ public fun MessageText(
top = 8.dp,
bottom = 8.dp,
)
- .testTag("Stream_MessageClickableText"),
+ .testTag("Stream_MessageClickableText")
+ .then(quoteRails),
text = styledText,
style = style,
onLongPress = { onLongItemClick(message) },
+ onTextLayout = { layout.value = it },
) { position ->
- val annotation = annotations.firstOrNull { position in it.start..it.end }
+ val annotation = annotations.firstOrNull {
+ it.isClickableTag() && position in it.start until it.end
+ }
if (annotation?.tag == AnnotationTagMention) {
message.mentionedUsers.getUserByNameOrId(annotation.item)?.let { onUserMentionClick.invoke(it) }
} else {
val targetUrl = annotation?.item
if (!targetUrl.isNullOrEmpty()) {
onLinkClick?.invoke(message, targetUrl) ?: run {
- context.startActivity(
- Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl)),
- )
+ try {
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl)),
+ )
+ } catch (_: ActivityNotFoundException) {
+ // Nothing guarantees an app exists for the link's scheme, and a tap on
+ // one must not bring the message list down.
+ }
}
}
}
@@ -142,13 +160,28 @@ public fun MessageText(
vertical = verticalPadding,
)
.clipToBounds()
- .testTag("Stream_MessageText"),
+ .testTag("Stream_MessageText")
+ .then(quoteRails),
text = styledText,
style = style,
+ onTextLayout = { layout.value = it },
)
}
}
+/**
+ * Whether a tap on this annotation should be acted on. A block quote's annotation covers every
+ * character of the quote and carries its depth, so leaving it in would answer a tap inside a quote
+ * with the depth in place of the link, mention or email underneath.
+ */
+internal fun AnnotatedString.Range.isClickableTag(): Boolean = when (tag) {
+ AnnotationTagUrl,
+ AnnotationTagEmail,
+ AnnotationTagMention,
+ -> true
+ else -> false
+}
+
/**
* A spin-off of a Foundation component that allows calling long press handlers.
* Contains only one additional parameter.
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AnnotatedString.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AnnotatedString.kt
index d7216f21712..dd534a8abe8 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AnnotatedString.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AnnotatedString.kt
@@ -76,3 +76,22 @@ internal fun AnnotatedString.Builder.merge(annotated: AnnotatedString) {
addTtsAnnotations(annotated.ttsAnnotations)
addUrlAnnotations(annotated.urlAnnotations)
}
+
+/**
+ * The text with a line feed wherever a paragraph starts one, since a paragraph break is a line the
+ * layout draws rather than a character, and an announcement can only read characters.
+ */
+internal fun AnnotatedString.textWithParagraphBreaks(): String {
+ // Unconditionally: a paragraph break renders a line of its own, on top of any line feed
+ // already sitting in front of it, which is how a blank line between two of them is made.
+ val starts = paragraphStyles
+ .map { it.start }
+ .filterTo(mutableSetOf()) { it > 0 }
+ if (starts.isEmpty()) return text
+ return buildString(text.length + starts.size) {
+ text.forEachIndexed { index, character ->
+ if (index in starts) append('\n')
+ append(character)
+ }
+ }
+}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/BlockQuoteRails.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/BlockQuoteRails.kt
new file mode 100644
index 00000000000..136770a764f
--- /dev/null
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/BlockQuoteRails.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.style.ResolvedTextDirection
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import androidx.compose.ui.unit.dp
+
+/**
+ * Draws the rail beside every rendered line of a block quote, in [color], reading the quotes from
+ * the [AnnotationTagBlockQuote] ranges of [annotations] and their extent from [layout].
+ *
+ * Drawn rather than written as a marker character, because the lines a quote occupies are only
+ * known once the text has been laid out. A character can be placed on a line break the renderer
+ * made, never on one the layout chose, and it leaves a gap between lines besides.
+ */
+internal fun Modifier.blockQuoteRails(
+ annotations: List>,
+ layout: () -> TextLayoutResult?,
+ color: Color,
+ indentPerDepth: TextUnit,
+): Modifier {
+ val quotes = annotations.filter { it.tag == AnnotationTagBlockQuote }
+ if (quotes.isEmpty()) return this
+ return drawBehind {
+ val laidOut = layout() ?: return@drawBehind
+ val step = indentPerDepth.toPx(this, laidOut)
+ val width = RailWidth.toPx()
+ quotes.forEach { quote ->
+ val depth = quote.item.toIntOrNull() ?: return@forEach
+ // Centred in the space the last level of indent opened up.
+ val offset = step * (depth - 1) + (step - width) / 2
+ val lines = laidOut.lineRange(quote) ?: return@forEach
+ // Mirrored for a quote running right to left, since the indent it sits in is
+ // start-relative. Taken from the paragraph rather than the layout direction, because
+ // one message can carry a quote of each direction.
+ val left = when (laidOut.getParagraphDirection(quote.start)) {
+ ResolvedTextDirection.Rtl -> size.width - offset - width
+ else -> offset
+ }
+ for (line in lines) {
+ val top = laidOut.getLineTop(line)
+ drawRect(
+ color = color,
+ topLeft = Offset(left, top),
+ size = Size(width, laidOut.getLineBottom(line) - top),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Every line the quote occupies, blank ones included, so the rail runs unbroken through the gap
+ * between two paragraphs of the same quote.
+ */
+private fun TextLayoutResult.lineRange(quote: AnnotatedString.Range): IntRange? {
+ val last = (quote.end - 1).coerceAtLeast(quote.start)
+ if (quote.start >= layoutInput.text.length) return null
+ return getLineForOffset(quote.start)..getLineForOffset(last.coerceAtMost(layoutInput.text.length - 1))
+}
+
+/** Resolves against the laid-out font size, since the indent is expressed relative to the text. */
+private fun TextUnit.toPx(density: Density, layout: TextLayoutResult): Float {
+ val fontSize = layout.layoutInput.style.fontSize
+ return when {
+ type == TextUnitType.Sp -> with(density) { toPx() }
+ type == TextUnitType.Em && fontSize.type == TextUnitType.Sp ->
+ value * with(density) { fontSize.toPx() }
+ else -> 0f
+ }
+}
+
+private val RailWidth = 2.dp
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MarkdownMessageTextFormatter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MarkdownMessageTextFormatter.kt
new file mode 100644
index 00000000000..019a3937a45
--- /dev/null
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MarkdownMessageTextFormatter.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import io.getstream.chat.android.compose.ui.theme.StreamTypography
+import io.getstream.chat.android.compose.ui.util.internal.MarkdownRenderer
+import io.getstream.chat.android.models.Message
+import io.getstream.chat.android.models.User
+import io.getstream.chat.android.ui.common.utils.extensions.isMine
+
+/**
+ * Renders message text as markdown. Built by
+ * [MessageTextFormatter.markdownFormatter], which documents what is and is not supported.
+ */
+@Suppress("LongParameterList")
+internal class MarkdownMessageTextFormatter(
+ private val autoTranslationEnabled: Boolean,
+ private val typography: StreamTypography,
+ private val styles: MarkdownStyles,
+ private val textStyle: (isMine: Boolean, message: Message) -> TextStyle,
+ private val linkStyle: (isMine: Boolean) -> TextStyle,
+ private val mentionColor: (isMine: Boolean) -> Color,
+ private val builder: AnnotatedMessageTextBuilder?,
+) : MessageTextFormatter {
+
+ private val renderer = MarkdownRenderer(styles)
+
+ override fun format(message: Message, currentUser: User?): AnnotatedString {
+ val displayedText = message.resolveDisplayedText(currentUser, autoTranslationEnabled)
+ val isMine = message.isMine(currentUser)
+ val baseStyle = SpanStyle(
+ fontStyle = typography.body.fontStyle,
+ color = textStyle(isMine, message).color,
+ )
+ val markdown = renderer.render(displayedText)
+ // Colour only: a full text style would overwrite the weight and size markdown set, both
+ // for the links markdown carried and for the ones the entity pass detects.
+ val link = linkStyle(isMine).let { TextStyle(color = it.color, textDecoration = it.textDecoration) }
+ return buildAnnotatedString {
+ append(markdown.text)
+ // The base style goes on first so the markdown spans layered over it win.
+ addStyle(baseStyle, start = 0, end = markdown.text.length)
+ addParagraphStyles(markdown.paragraphStyles)
+ markdown.spanStyles.forEach { addStyle(it.item, it.start, it.end) }
+ markdown.getStringAnnotations(0, markdown.length).forEach {
+ addStringAnnotation(it.tag, it.item, it.start, it.end)
+ // The entity pass below skips annotated ranges, so markdown links style here.
+ if (it.tag == AnnotationTagUrl) addStyle(link.toSpanStyle(), it.start, it.end)
+ }
+ }
+ .annotateStreamEntities(
+ mentionedUserNames = message.mentionedUsers.map { it.name.ifEmpty { it.id } },
+ linkStyle = link,
+ mentionsColor = mentionColor(isMine),
+ )
+ .let { annotated ->
+ val extra = builder ?: return@let annotated
+ buildAnnotatedString {
+ append(annotated)
+ extra.invoke(this, message, currentUser)
+ }
+ }
+ }
+}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MarkdownStyles.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MarkdownStyles.kt
new file mode 100644
index 00000000000..7a11e0540aa
--- /dev/null
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MarkdownStyles.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.em
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.compose.ui.theme.StreamColors
+import io.getstream.chat.android.compose.ui.theme.StreamTypography
+
+/**
+ * Styling for the markdown constructs rendered inside a message bubble.
+ *
+ * @param listIndent Indents a list once per level of nesting, wrapped lines included.
+ * @param blockQuoteIndent Sets a quote in from the margin once per level, leaving room for
+ * the rail drawn beside it.
+ * @param thematicBreak Stands in for a thematic break (`---`).
+ */
+@Immutable
+internal data class MarkdownStyles(
+ val headings: List,
+ val codeSpan: SpanStyle,
+ val codeBlock: SpanStyle,
+ val blockQuote: SpanStyle,
+ val listIndent: TextUnit = 1.em,
+ val blockQuoteIndent: TextUnit = BlockQuoteIndent,
+ val thematicBreak: String = "⸻",
+) {
+
+ /** The style for a heading of the given [level], counting from 1. */
+ fun heading(level: Int): SpanStyle = headings.getOrElse(level - 1) { headings.last() }
+
+ companion object {
+
+ /** Shared with whatever draws the rail, so the two agree on where it goes. */
+ val BlockQuoteIndent: TextUnit = 1.em
+
+ /** Builds the default styling from the design system. */
+ @Composable
+ fun defaults(
+ typography: StreamTypography = ChatTheme.typography,
+ colors: StreamColors = ChatTheme.colors,
+ ): MarkdownStyles = MarkdownStyles(
+ // Four sizes for six levels, so two pairs collide. The fourth stays at body size,
+ // as it does on the web, and the sixth is set apart by colour. Every level is
+ // weighted above body text, which is what carries a heading where sizes run out.
+ headings = listOf(
+ typography.title1.toSpanStyle(),
+ typography.title3Bold.toSpanStyle(),
+ typography.bodyBold.toSpanStyle(),
+ typography.bodyBold.toSpanStyle(),
+ typography.footnoteBold.toSpanStyle(),
+ typography.footnoteBold.toSpanStyle().copy(color = colors.textLowEmphasis),
+ ),
+ codeSpan = SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ background = colors.inputBackground,
+ ),
+ codeBlock = SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ background = colors.inputBackground,
+ ),
+ blockQuote = SpanStyle(color = colors.textLowEmphasis),
+ )
+ }
+}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageTextFormatter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageTextFormatter.kt
index c9663b3eaa5..a518c84d147 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageTextFormatter.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageTextFormatter.kt
@@ -28,7 +28,6 @@ import io.getstream.chat.android.compose.ui.theme.StreamShapes
import io.getstream.chat.android.compose.ui.theme.StreamTypography
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.User
-import io.getstream.chat.android.ui.common.feature.messages.translations.MessageOriginalTranslationsStore
import io.getstream.chat.android.ui.common.utils.extensions.isMine
/**
@@ -137,6 +136,48 @@ public fun interface MessageTextFormatter {
)
}
+ /**
+ * Builds a formatter that renders the message text as markdown, in place of
+ * [defaultFormatter]:
+ * ```
+ * ChatTheme(
+ * messageTextFormatter = MessageTextFormatter.markdownFormatter(autoTranslationEnabled = true),
+ * )
+ * ```
+ *
+ * Mentions, links and emails are highlighted on top of the rendered markdown. Rendering
+ * changes the text's length, so offsets no longer line up with [Message.text] and this
+ * cannot be combined through [composite], which styles by those offsets. Styling follows
+ * [typography] and [colors].
+ *
+ * A single line break renders as a line break, where the specification collapses it to a
+ * space. Complying would leave two trailing spaces as the only way to write one, and would
+ * reflow every multi-line message that reads correctly as plain text today. The View-based
+ * kit deviates the same way.
+ *
+ * Emphasis, strikethrough, code, headings, lists, quotes and links are rendered. An image
+ * falls back to its alt text, and a table or task list to its source, since none of the
+ * three can be drawn in a styled string.
+ */
+ @Composable
+ public fun markdownFormatter(
+ autoTranslationEnabled: Boolean,
+ isInDarkMode: Boolean = isSystemInDarkTheme(),
+ typography: StreamTypography = StreamTypography.defaultTypography(),
+ colors: StreamColors = when (isInDarkMode) {
+ true -> StreamColors.defaultDarkColors()
+ else -> StreamColors.defaultColors()
+ },
+ ): MessageTextFormatter = MarkdownMessageTextFormatter(
+ autoTranslationEnabled = autoTranslationEnabled,
+ typography = typography,
+ styles = MarkdownStyles.defaults(typography = typography, colors = colors),
+ textStyle = defaultTextStyle(isInDarkMode, typography, colors),
+ linkStyle = defaultLinkStyle(colors),
+ mentionColor = defaultMentionColor(isInDarkMode, typography, colors),
+ builder = null,
+ )
+
/**
* Builds a composite message text formatter.
*
@@ -185,22 +226,7 @@ private class DefaultMessageTextFormatter(
) : MessageTextFormatter {
override fun format(message: Message, currentUser: User?): AnnotatedString {
- val displayedText = when (autoTranslationEnabled) {
- true -> {
- // If auto-translation is enabled, we check if the message is showing original text.
- // If it is, we return the original text, otherwise we return the translated text.
- if (MessageOriginalTranslationsStore.forChannel(message.cid).shouldShowOriginalText(message.id)) {
- message.text
- } else {
- // If the message is not showing original text, we check if the current user has a language set.
- // If they do, we return the translated text, otherwise we return the original text.
- currentUser?.language?.let { userLanguage ->
- message.getTranslation(userLanguage).ifEmpty { message.text }
- } ?: message.text
- }
- }
- else -> message.text
- }
+ val displayedText = message.resolveDisplayedText(currentUser, autoTranslationEnabled)
val mentionedUserNames = message.mentionedUsers.map { it.name.ifEmpty { it.id } }
val isMine = message.isMine(currentUser)
val textColor = textStyle(isMine, message).color
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageUtils.kt
index 4ff8c06da4c..00bb1de376c 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageUtils.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessageUtils.kt
@@ -93,3 +93,14 @@ internal fun Message.isEmojiOnlyWithoutBubble(): Boolean = isFewEmoji() &&
* Max number of emoji without showing it inside a bubble.
*/
internal const val MaxFullSizeEmoji: Int = 3
+
+/**
+ * The text to display for this message, honouring auto-translation and the per-message "show
+ * original text" toggle. [currentUser]'s language selects the translation.
+ */
+internal fun Message.resolveDisplayedText(currentUser: User?, autoTranslationEnabled: Boolean): String {
+ if (!autoTranslationEnabled) return text
+ if (MessageOriginalTranslationsStore.forChannel(cid).shouldShowOriginalText(id)) return text
+ val userLanguage = currentUser?.language ?: return text
+ return getTranslation(userLanguage).ifEmpty { text }
+}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt
index 7408d4b1865..e1e3495b69e 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt
@@ -48,6 +48,15 @@ internal const val AnnotationTagEmail: AnnotationTag = "EMAIL"
*/
internal const val AnnotationTagMention: AnnotationTag = "MENTION"
+/**
+ * The tag marking text to take literally, such as markdown code, so that nothing inside it is
+ * detected as a URL, an email or a mention.
+ */
+internal const val AnnotationTagLiteral: AnnotationTag = "LITERAL"
+
+/** Marks a block quote's span, carrying its depth of nesting, so its rail can be drawn. */
+internal const val AnnotationTagBlockQuote: AnnotationTag = "BLOCK_QUOTE"
+
/**
* Builds an [AnnotatedString] from a given text, applying styles and annotations for links and mentions.
* Used in message bubbles.
@@ -82,25 +91,9 @@ internal fun buildAnnotatedMessageText(
end = text.length,
)
- // Then for each available link in the text, we add a different style, to represent the links,
- // as well as add a String annotation to it. This gives us the ability to open the URL on click.
- linkify(
- text = text,
- tag = AnnotationTagUrl,
- pattern = PatternsCompat.AUTOLINK_WEB_URL,
- matchFilter = Linkify.sUrlMatchFilter,
- schemes = URL_SCHEMES,
- textStyle = linkStyle,
- )
- linkify(
- text = text,
- tag = AnnotationTagEmail,
- pattern = PatternsCompat.AUTOLINK_EMAIL_ADDRESS,
- schemes = EMAIL_SCHEMES,
- textStyle = linkStyle,
- )
- tagUser(
+ annotateEntities(
text = text,
+ linkStyle = linkStyle,
mentionsColor = mentionsColor,
mentionedUserNames = mentionedUserNames,
)
@@ -110,6 +103,80 @@ internal fun buildAnnotatedMessageText(
}
}
+/**
+ * Adds the annotations Stream recognises in message text, URLs, emails and the mentions named by
+ * [mentionedUserNames], on top of an already styled string.
+ *
+ * Ranges already tagged [AnnotationTagUrl] keep the destination they were built with, and ranges
+ * tagged [AnnotationTagLiteral] are left as written.
+ *
+ * @param mentionedUserNames The names to highlight, each without its leading `@`.
+ * @param linkStyle The style applied to URLs and emails.
+ * @param mentionsColor Applied to every mention.
+ */
+internal fun AnnotatedString.annotateStreamEntities(
+ mentionedUserNames: List,
+ linkStyle: TextStyle,
+ mentionsColor: Color,
+): AnnotatedString {
+ val styled = this
+ return buildAnnotatedString {
+ append(styled.text)
+ addSpanStyles(styled.spanStyles)
+ addParagraphStyles(styled.paragraphStyles)
+ // Everything but the literal markers, which exist only for the pass below.
+ addStringAnnotations(styled.stringAnnotations.filter { it.tag != AnnotationTagLiteral })
+ annotateEntities(
+ text = styled.text,
+ linkStyle = linkStyle,
+ mentionsColor = mentionsColor,
+ mentionedUserNames = mentionedUserNames,
+ skipRanges = styled.stringAnnotations
+ .filter { it.tag == AnnotationTagUrl || it.tag == AnnotationTagLiteral }
+ .map { it.start until it.end },
+ )
+ }
+}
+
+/**
+ * Styles and annotates every URL, email and mention in [text], which the receiver must already
+ * hold as its content for the match offsets to line up.
+ */
+@SuppressLint("RestrictedApi")
+private fun AnnotatedString.Builder.annotateEntities(
+ text: String,
+ linkStyle: TextStyle,
+ mentionsColor: Color,
+ mentionedUserNames: List,
+ skipRanges: List = emptyList(),
+) {
+ // For each available link in the text, we add a different style, to represent the links,
+ // as well as add a String annotation to it. This gives us the ability to open the URL on click.
+ linkify(
+ text = text,
+ tag = AnnotationTagUrl,
+ pattern = PatternsCompat.AUTOLINK_WEB_URL,
+ matchFilter = Linkify.sUrlMatchFilter,
+ schemes = URL_SCHEMES,
+ textStyle = linkStyle,
+ skipRanges = skipRanges,
+ )
+ linkify(
+ text = text,
+ tag = AnnotationTagEmail,
+ pattern = PatternsCompat.AUTOLINK_EMAIL_ADDRESS,
+ schemes = EMAIL_SCHEMES,
+ textStyle = linkStyle,
+ skipRanges = skipRanges,
+ )
+ tagUser(
+ text = text,
+ mentionsColor = mentionsColor,
+ mentionedUserNames = mentionedUserNames,
+ skipRanges = skipRanges,
+ )
+}
+
/**
* Builds an [AnnotatedString] from a given text, applying styles and annotations for links and mentions.
* Used in message input fields.
@@ -200,6 +267,7 @@ private fun AnnotatedString.Builder.linkify(
matchFilter: Linkify.MatchFilter? = null,
schemes: List,
textStyle: TextStyle,
+ skipRanges: List = emptyList(),
) {
@SuppressLint("RestrictedApi")
val matcher = pattern.matcher(text)
@@ -207,7 +275,9 @@ private fun AnnotatedString.Builder.linkify(
val start = matcher.start()
val end = matcher.end()
- if (matchFilter != null && !matchFilter.acceptMatch(text, start, end)) {
+ val rejected = (matchFilter != null && !matchFilter.acceptMatch(text, start, end)) ||
+ skipRanges.any { start <= it.last && it.first < end }
+ if (rejected) {
continue
}
@@ -234,12 +304,14 @@ private fun AnnotatedString.Builder.tagUser(
text: String,
mentionsColor: Color,
mentionedUserNames: List,
+ skipRanges: List = emptyList(),
) {
mentionedUserNames.forEach { userName ->
val start = text.indexOf(userName)
val end = start + userName.length
if (start < 0) return@forEach
+ if (skipRanges.any { start <= it.last && it.first < end }) return@forEach
addStyle(
style = SpanStyle(
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/internal/MarkdownEmitter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/internal/MarkdownEmitter.kt
new file mode 100644
index 00000000000..7810a2018f1
--- /dev/null
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/internal/MarkdownEmitter.kt
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util.internal
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.ParagraphStyle
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextIndent
+import androidx.compose.ui.unit.TextUnit
+
+/**
+ * Collects text, styles and annotations while the markdown tree is walked, then assembles them into
+ * an [AnnotatedString].
+ *
+ * Styles are recorded against offsets rather than pushed and popped, because block constructs are
+ * styled only once their whole content has been emitted.
+ */
+internal class MarkdownEmitter {
+
+ private val text = StringBuilder()
+ private var linePrefix = ""
+ private val spanStyles = mutableListOf>()
+ private val annotations = mutableListOf>()
+ private var indent = TextUnit.Unspecified
+ private val paragraphStarts = mutableMapOf()
+
+ val length: Int get() = text.length
+
+ fun append(value: CharSequence) {
+ text.append(value)
+ }
+
+ /**
+ * Ends the current line and opens the next with [linePrefix], so a construct marking every one
+ * of its lines keeps doing so. Every call breaks, so two breaks in the source stay two.
+ */
+ fun appendLineBreak() {
+ openLine()
+ }
+
+ val currentLinePrefix: String get() = linePrefix
+
+ /**
+ * Whether lines currently open with a prefix. While they do, blocks stay separated by
+ * characters, so the prefix reaches the blank line between two of them; a paragraph break
+ * would leave that line bare and cut the marker in two.
+ */
+ private val linesArePrefixed: Boolean get() = linePrefix.isNotEmpty()
+
+ /**
+ * Indents every line [block] emits by [indent], including the ones the layout wraps, so a
+ * wrapped line keeps the horizontal position its own item started at.
+ */
+ fun withIndent(indent: TextUnit, block: () -> Unit) {
+ val previous = this.indent
+ this.indent = indent
+ try {
+ block()
+ } finally {
+ this.indent = previous
+ }
+ }
+
+ /**
+ * Breaks the line by starting a new paragraph, which is also what carries the indent. Adds no
+ * line feed of its own, since a paragraph break already renders as one line break; one here
+ * as well would leave a blank line between the two paragraphs.
+ */
+ fun startParagraph() {
+ paragraphStarts[text.length] = indent
+ }
+
+ /** Marks every line [block] emits with [prefix], as a block quote marks its whole span. */
+ fun withLinePrefix(prefix: String, block: () -> Unit) {
+ val previous = linePrefix
+ linePrefix = prefix
+ try {
+ block()
+ } finally {
+ linePrefix = previous
+ }
+ }
+
+ private fun openLine() {
+ text.append('\n').append(linePrefix)
+ }
+
+ fun addSpan(style: SpanStyle, start: Int, end: Int = length) {
+ if (end > start) spanStyles += AnnotatedString.Range(style, start, end)
+ }
+
+ fun addAnnotation(tag: String, value: String, start: Int, end: Int = length) {
+ if (end > start) annotations += AnnotatedString.Range(value, start, end, tag)
+ }
+
+ /**
+ * Separates the block just emitted from the next with [newlines] breaks, counting those already
+ * present. Does nothing while the output is empty, so it never starts with a blank line.
+ *
+ * A paragraph break renders as one line break by itself, so it stands in for the first of the
+ * breaks rather than being added on top of them.
+ */
+ fun endBlock(newlines: Int) {
+ if (text.isEmpty()) return
+ val breaksParagraph = !linesArePrefixed
+ val literal = if (breaksParagraph) newlines - 1 else newlines
+ var present = 0
+ var end = text.length
+ val opening = "\n$linePrefix"
+ while (end >= opening.length && text.substring(end - opening.length, end) == opening) {
+ present++
+ end -= opening.length
+ }
+ repeat((literal - present).coerceAtLeast(0)) { openLine() }
+ // Before the prefix, so the prefix opens the next paragraph instead of closing the last.
+ if (breaksParagraph) paragraphStarts[text.length - linePrefix.length] = indent
+ }
+
+ /** Drops trailing blank lines, so a trailing block separator does not pad the bubble. */
+ fun trimTrailingNewlines() {
+ val opening = "\n$linePrefix"
+ while (text.isNotEmpty()) {
+ when {
+ text.endsWith(opening) -> text.setLength(text.length - opening.length)
+ text.last() == '\n' -> text.setLength(text.length - 1)
+ else -> break
+ }
+ }
+ clampAll()
+ }
+
+ private fun clampAll() {
+ clamp(spanStyles)
+ clamp(annotations)
+ paragraphStarts.keys.retainAll { it in 0 until text.length }
+ }
+
+ private fun clamp(ranges: MutableList>) {
+ val limit = text.length
+ for (index in ranges.indices.reversed()) {
+ val range = ranges[index]
+ when {
+ range.start >= limit -> ranges.removeAt(index)
+ range.end > limit -> ranges[index] =
+ AnnotatedString.Range(range.item, range.start, limit, range.tag)
+ }
+ }
+ }
+
+ fun build(): AnnotatedString = buildAnnotatedString {
+ append(text.toString())
+ paragraphStyles().forEach { addStyle(it.item, it.start, it.end) }
+ spanStyles.forEach { addStyle(it.item, it.start, it.end) }
+ annotations.forEach { addStringAnnotation(it.tag, it.item, it.start, it.end) }
+ }
+
+ /**
+ * One style per paragraph, covering the whole output, carrying the indent recorded where the
+ * paragraph starts. The ranges have to be contiguous and complete, because their edges are the
+ * paragraph breaks that [endBlock] counted on for separation.
+ */
+ private fun paragraphStyles(): List> {
+ if (text.isEmpty()) return emptyList()
+ val bounds = (paragraphStarts.keys + 0).sorted() + text.length
+ return bounds.zipWithNext { start, end ->
+ AnnotatedString.Range(ParagraphStyle(textIndent = indentFor(start)), start, end)
+ }
+ }
+
+ private fun indentFor(start: Int): TextIndent? = paragraphStarts[start]
+ ?.takeIf { it != TextUnit.Unspecified }
+ ?.let { TextIndent(firstLine = it, restLine = it) }
+}
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/internal/MarkdownRenderer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/internal/MarkdownRenderer.kt
new file mode 100644
index 00000000000..967fd0031ea
--- /dev/null
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/internal/MarkdownRenderer.kt
@@ -0,0 +1,632 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util.internal
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import io.getstream.chat.android.compose.ui.util.AnnotationTagBlockQuote
+import io.getstream.chat.android.compose.ui.util.AnnotationTagLiteral
+import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl
+import io.getstream.chat.android.compose.ui.util.MarkdownStyles
+import io.getstream.log.taggedLogger
+import org.intellij.markdown.IElementType
+import org.intellij.markdown.MarkdownElementTypes
+import org.intellij.markdown.MarkdownTokenTypes
+import org.intellij.markdown.ast.ASTNode
+import org.intellij.markdown.ast.getTextInNode
+import org.intellij.markdown.flavours.gfm.GFMElementTypes
+import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
+import org.intellij.markdown.flavours.gfm.GFMTokenTypes
+import org.intellij.markdown.parser.LinkMap
+import org.intellij.markdown.parser.MarkdownParser
+
+/**
+ * Renders markdown as an [AnnotatedString]: inline constructs become spans, blocks are laid out
+ * with line breaks and indentation. A construct with no styled form falls back to something
+ * readable, an image to its alt text and a table to its source.
+ */
+internal class MarkdownRenderer(private val styles: MarkdownStyles) {
+
+ private val logger by taggedLogger("Chat:MarkdownRenderer")
+
+ fun render(text: String): AnnotatedString {
+ // The parser only recognises line feeds; a carriage return would reach the output.
+ val source = text.replace("\r\n", "\n").replace('\r', '\n')
+ // Runs during composition on text from other people, and nesting deep enough exhausts the
+ // stack while parsing. Failing here would take the message list down on every reopen.
+ return try {
+ renderOrThrow(source)
+ } catch (@Suppress("TooGenericExceptionCaught") error: Throwable) {
+ logger.e(error) { "[render] failed, falling back to plain text" }
+ AnnotatedString(source)
+ }
+ }
+
+ private fun renderOrThrow(source: String): AnnotatedString {
+ val emitter = MarkdownEmitter()
+ val tree = MarkdownParser(GFMFlavourDescriptor()).buildMarkdownTreeFromString(source)
+ val links = LinkMap.buildLinkMap(tree, source)
+ Walker(source, styles, emitter, links).visitBlocks(tree.children)
+ emitter.trimTrailingNewlines()
+ val rendered = emitter.build()
+ // A message holding only link definitions renders nothing, so show what was typed.
+ return when {
+ rendered.text.isEmpty() && source.isNotEmpty() -> AnnotatedString(source)
+ else -> rendered
+ }
+ }
+}
+
+// A visitor needs a member per construct, and markdown has more than the threshold allows.
+@Suppress("TooManyFunctions")
+private class Walker(
+ private val source: String,
+ private val styles: MarkdownStyles,
+ private val emitter: MarkdownEmitter,
+ private val links: LinkMap,
+) {
+
+ private var quoteDepth = 0
+
+ fun visitBlocks(nodes: List) {
+ // The breaks between two blocks are how the author spaced them, so carry that across
+ // rather than imposing a fixed separation per block type.
+ var breaks = 0
+ var started = false
+ for (node in nodes) {
+ when (node.type) {
+ MarkdownTokenTypes.EOL -> if (started) breaks++
+ MarkdownTokenTypes.WHITE_SPACE -> Unit
+ else -> {
+ if (started) emitter.endBlock(breaks.coerceIn(1, MaxBlockBreaks))
+ breaks = 0
+ started = true
+ visitBlock(node)
+ }
+ }
+ }
+ }
+
+ private fun visitBlock(node: ASTNode) {
+ when (node.type) {
+ MarkdownElementTypes.PARAGRAPH -> visitInlineChildren(node)
+
+ MarkdownElementTypes.ATX_1 -> heading(node, level = 1)
+ MarkdownElementTypes.ATX_2 -> heading(node, level = 2)
+ MarkdownElementTypes.ATX_3 -> heading(node, level = 3)
+ MarkdownElementTypes.ATX_4 -> heading(node, level = 4)
+ MarkdownElementTypes.ATX_5 -> heading(node, level = 5)
+ MarkdownElementTypes.ATX_6 -> heading(node, level = 6)
+ MarkdownElementTypes.SETEXT_1 -> heading(node, level = 1)
+ MarkdownElementTypes.SETEXT_2 -> heading(node, level = 2)
+
+ MarkdownElementTypes.BLOCK_QUOTE -> blockQuote(node)
+ MarkdownElementTypes.UNORDERED_LIST, MarkdownElementTypes.ORDERED_LIST -> list(node, level = 1)
+ MarkdownElementTypes.CODE_FENCE -> codeBlock(node, contentType = MarkdownTokenTypes.CODE_FENCE_CONTENT)
+ MarkdownElementTypes.CODE_BLOCK ->
+ codeBlock(node, contentType = MarkdownTokenTypes.CODE_LINE, stripIndent = true)
+
+ MarkdownTokenTypes.HORIZONTAL_RULE -> emitter.append(styles.thematicBreak)
+
+ // No styled form, so the source stands in, but both are still blocks.
+ GFMElementTypes.TABLE, MarkdownElementTypes.HTML_BLOCK -> verbatimBlock(node)
+
+ // A definition declares a reference target and renders nothing itself.
+ MarkdownElementTypes.LINK_DEFINITION -> Unit
+
+ // endBlock drives the separation, so structural breaks are dropped.
+ MarkdownTokenTypes.EOL, MarkdownTokenTypes.WHITE_SPACE -> Unit
+
+ else -> visitInlineNode(node)
+ }
+ }
+
+ /**
+ * Emits a construct's source as written, less the quote markers its continuation lines carry,
+ * which the line prefix already stands for.
+ */
+ private fun verbatimBlock(node: ASTNode) {
+ val start = emitter.length
+ node.text().toString().split('\n').forEachIndexed { index, line ->
+ if (index > 0) emitter.appendLineBreak()
+ // Appended rather than resolved, so an escape or a reference stays as it was written.
+ emitter.append(if (index > 0) QuoteMarkers.replace(line, "") else line)
+ }
+ emitter.addAnnotation(AnnotationTagLiteral, "", start)
+ }
+
+ private fun heading(node: ASTNode, level: Int) {
+ val start = emitter.length
+ val content = node.children.filter { it.type in HeadingContentTypes }
+ if (content.isEmpty()) {
+ // Setext headings hold their text directly, ATX headings wrap it in a content node.
+ visitInlineChildren(node, skip = HeadingMarkerTypes)
+ } else {
+ // The content node keeps the spaces separating the text from either marker.
+ content.forEach {
+ visitInlineNodes(
+ it.children.dropWhile(ASTNode::isWhitespace).dropLastWhile(ASTNode::isWhitespace),
+ )
+ }
+ }
+ emitter.addSpan(styles.heading(level), start)
+ }
+
+ /**
+ * Sets the quote in from the margin and marks its span with the depth, leaving the rail to be
+ * drawn. A marker character could only land on a line this walker broke itself, so it would be
+ * missing from every line the layout wrapped.
+ */
+ private fun blockQuote(node: ASTNode) {
+ val start = emitter.length
+ val depth = quoteDepth + 1
+ quoteDepth = depth
+ emitter.withIndent(styles.blockQuoteIndent * depth) {
+ emitter.startParagraph()
+ visitBlocks(node.children.filter { it.type != MarkdownTokenTypes.BLOCK_QUOTE })
+ emitter.trimTrailingNewlines()
+ }
+ quoteDepth = depth - 1
+ emitter.addSpan(styles.blockQuote, start)
+ emitter.addAnnotation(AnnotationTagBlockQuote, depth.toString(), start)
+ }
+
+ private fun list(node: ASTNode, level: Int) {
+ val items = node.children.filter { it.type == MarkdownElementTypes.LIST_ITEM }
+ val ordered = node.type == MarkdownElementTypes.ORDERED_LIST
+ // Numbered from the first marker on, so a list written entirely as "1." reads 1, 2, 3.
+ val firstNumber = items.firstNotNullOfOrNull(::orderedMarkerNumber) ?: 1
+ emitter.withIndent(styles.listIndent * (level - 1)) {
+ items.forEachIndexed { index, item ->
+ // Every item is a paragraph of its own, which is what lets each one keep the
+ // indent of its own level once the layout wraps it.
+ emitter.startParagraph()
+ val marker = when {
+ ordered -> "${firstNumber + index}. "
+ else -> "$UnorderedListMarker "
+ }
+ listItem(item, level, marker)
+ }
+ }
+ }
+
+ private fun orderedMarkerNumber(item: ASTNode): Int? = item.children
+ .firstOrNull { it.type == MarkdownTokenTypes.LIST_NUMBER }
+ ?.text()
+ ?.trimStart()
+ ?.takeWhile(Char::isDigit)
+ ?.toString()
+ ?.toIntOrNull()
+
+ private fun listItem(node: ASTNode, level: Int, marker: String) {
+ emitter.append(marker)
+
+ // Whatever comes first shares the marker's line, so no marker is left alone on one.
+ // Everything after starts its own line, indented under the item's text.
+ var markerLineTaken = false
+ for (child in node.children) {
+ when (child.type) {
+ // A checkbox belongs beside the marker, so it must not take the line.
+ GFMTokenTypes.CHECK_BOX -> emitter.append(child.text())
+
+ // Markers are replaced, and the breaks between an item's blocks are structural.
+ MarkdownTokenTypes.LIST_BULLET,
+ MarkdownTokenTypes.LIST_NUMBER,
+ MarkdownTokenTypes.EOL,
+ MarkdownTokenTypes.WHITE_SPACE,
+ -> Unit
+
+ MarkdownElementTypes.PARAGRAPH -> {
+ if (markerLineTaken) continueItemLine()
+ markerLineTaken = true
+ // Items hold content in paragraphs; as blocks they would gain blank lines.
+ visitInlineChildren(child)
+ }
+
+ // A nested list opens paragraphs of its own, which are the breaks as well.
+ MarkdownElementTypes.UNORDERED_LIST, MarkdownElementTypes.ORDERED_LIST -> {
+ markerLineTaken = true
+ list(child, level + 1)
+ }
+
+ // Same for a quote, which also brings its own indent, so opening a line for it
+ // would leave an indented blank one behind.
+ MarkdownElementTypes.BLOCK_QUOTE -> {
+ markerLineTaken = true
+ blockQuote(child)
+ }
+
+ else -> {
+ if (markerLineTaken) continueItemLine()
+ markerLineTaken = true
+ emitter.withLinePrefix(emitter.currentLinePrefix + BlockInItemIndent) {
+ visitBlock(child)
+ // Trim under the item's prefix, or its last marked line is left dangling.
+ emitter.trimTrailingNewlines()
+ }
+ }
+ }
+ }
+ // No break of its own: the next item opens a paragraph, and the block separator after the
+ // last one supplies the blank line.
+ emitter.trimTrailingNewlines()
+ }
+
+ private fun endItemLine() {
+ emitter.trimTrailingNewlines()
+ emitter.endBlock(newlines = 1)
+ }
+
+ private fun continueItemLine() {
+ endItemLine()
+ emitter.append(BlockInItemIndent)
+ }
+
+ private fun codeBlock(node: ASTNode, contentType: IElementType, stripIndent: Boolean = false) {
+ val code = StringBuilder()
+ for (child in node.children) {
+ when (child.type) {
+ contentType -> code.append(child.text())
+ MarkdownTokenTypes.EOL -> code.append('\n')
+ else -> Unit
+ }
+ }
+ val start = emitter.length
+ // Line by line, so a code block inside a quote keeps the quote's marker on each.
+ code.trim('\n').split('\n').forEachIndexed { index, line ->
+ if (index > 0) emitter.appendLineBreak()
+ emitter.append(if (stripIndent) line.stripCodeIndent() else line)
+ }
+ emitter.addSpan(styles.codeBlock, start)
+ emitter.addAnnotation(AnnotationTagLiteral, "", start)
+ }
+
+ private fun visitInlineChildren(node: ASTNode, skip: Set = emptySet()) {
+ visitInlineNodes(node.children.filter { it.type !in skip })
+ }
+
+ private fun visitInlineNodes(nodes: List) {
+ // A quote marks every line, so a continuation line carries one inside the paragraph.
+ var afterQuoteMarker = false
+ var afterHardBreak = false
+ nodes.forEachIndexed { index, node ->
+ val bracketsEmailAutolink = node.type == MarkdownTokenTypes.LT &&
+ nodes.getOrNull(index + 1)?.type == MarkdownTokenTypes.EMAIL_AUTOLINK ||
+ node.type == MarkdownTokenTypes.GT &&
+ nodes.getOrNull(index - 1)?.type == MarkdownTokenTypes.EMAIL_AUTOLINK
+ when {
+ node.type == MarkdownTokenTypes.BLOCK_QUOTE -> afterQuoteMarker = true
+ afterQuoteMarker && node.type == MarkdownTokenTypes.WHITE_SPACE -> afterQuoteMarker = false
+ // A hard break is a marker plus the feed it sits on; only the marker breaks.
+ afterHardBreak && node.type == MarkdownTokenTypes.EOL -> afterHardBreak = false
+ // An email autolink is a bare token between brackets, unlike a URL autolink.
+ bracketsEmailAutolink -> afterQuoteMarker = false
+ else -> {
+ afterQuoteMarker = false
+ afterHardBreak = node.isHardBreak(source)
+ visitInlineNode(node)
+ }
+ }
+ }
+ }
+
+ private fun visitInlineNode(node: ASTNode) {
+ when (node.type) {
+ MarkdownElementTypes.EMPH -> styled(ItalicSpan) {
+ visitInlineChildren(node, skip = EmphasisMarkerTypes)
+ }
+
+ MarkdownElementTypes.STRONG -> styled(BoldSpan) {
+ visitInlineChildren(node, skip = EmphasisMarkerTypes)
+ }
+
+ GFMElementTypes.STRIKETHROUGH -> styled(StrikethroughSpan) {
+ visitInlineChildren(node, skip = EmphasisMarkerTypes)
+ }
+
+ MarkdownElementTypes.CODE_SPAN -> literal(styles.codeSpan) {
+ // The specification turns a line ending in a code span into a space.
+ val content = node.children
+ .dropWhile { it.type == MarkdownTokenTypes.BACKTICK }
+ .dropLastWhile { it.type == MarkdownTokenTypes.BACKTICK }
+ content.forEachIndexed { index, child ->
+ val opensQuotedLine =
+ content.getOrNull(index - 1)?.type == MarkdownTokenTypes.BLOCK_QUOTE
+ when {
+ child.type == MarkdownTokenTypes.BLOCK_QUOTE -> Unit
+ opensQuotedLine && child.type == MarkdownTokenTypes.WHITE_SPACE -> Unit
+ child.type == MarkdownTokenTypes.EOL -> emitter.append(" ")
+ else -> emitter.append(child.text())
+ }
+ }
+ }
+
+ MarkdownElementTypes.INLINE_LINK -> inlineLink(node)
+
+ MarkdownElementTypes.FULL_REFERENCE_LINK,
+ MarkdownElementTypes.SHORT_REFERENCE_LINK,
+ -> referenceLink(node)
+
+ // An image cannot be drawn, so its alt text stands in, as the specification says.
+ MarkdownElementTypes.IMAGE -> imageAltText(node)
+
+ // The brackets are syntax; the entity pass linkifies the URL like any bare one.
+ MarkdownElementTypes.AUTOLINK -> visitInlineChildren(node, skip = AutolinkMarkerTypes)
+ MarkdownTokenTypes.EMAIL_AUTOLINK, MarkdownTokenTypes.AUTOLINK -> emitter.append(node.text())
+
+ MarkdownTokenTypes.HARD_LINE_BREAK, MarkdownTokenTypes.EOL -> emitter.appendLineBreak()
+
+ MarkdownTokenTypes.HTML_TAG -> when {
+ node.isHardBreak(source) -> emitter.appendLineBreak()
+ else -> emitter.appendText(node.text())
+ }
+
+ else ->
+ if (node.children.isEmpty()) {
+ emitter.appendText(node.text())
+ } else {
+ visitInlineChildren(node)
+ }
+ }
+ }
+
+ /**
+ * Emits what a link, reference or image shows, annotated with [destination] when there is one
+ * worth opening. Showing nothing falls back to the source, so nothing vanishes.
+ */
+ private fun linkLike(node: ASTNode, label: ASTNode?, destination: String?) {
+ if (label == null) {
+ emitter.appendText(node.text())
+ return
+ }
+ val start = emitter.length
+ visitInlineChildren(label, skip = LinkLabelMarkerTypes)
+ if (emitter.length == start) {
+ emitter.appendText(node.text())
+ return
+ }
+ destination?.resolveMarkdownText()?.toOpenableUrl()?.let { url ->
+ emitter.addAnnotation(AnnotationTagUrl, url, start)
+ }
+ }
+
+ private fun inlineLink(node: ASTNode) {
+ val destination = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
+ when (destination) {
+ null -> emitter.appendText(node.text())
+ else -> linkLike(
+ node = node,
+ label = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT },
+ destination = destination.text().toString(),
+ )
+ }
+ }
+
+ /** Resolves `[text][label]` and `[label]` against the document's link definitions. */
+ private fun referenceLink(node: ASTNode) {
+ val label = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_LABEL }
+ val destination = label
+ ?.let { links.getLinkInfo(LinkMap.normalizeLabel(it.text())) }
+ ?.destination
+ when (destination) {
+ // With no definition to resolve against, the reference reads as it was written.
+ null -> emitter.appendText(node.text())
+ else -> linkLike(
+ node = node,
+ // A full reference shows its own text; a short one shows the label it was written as.
+ label = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT } ?: label,
+ destination = destination.toString(),
+ )
+ }
+ }
+
+ private fun imageAltText(node: ASTNode) {
+ val link = node.children.firstOrNull { it.type in ImageLinkTypes } ?: node
+ linkLike(
+ node = node,
+ label = link.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT }
+ ?: link.children.firstOrNull { it.type == MarkdownElementTypes.LINK_LABEL },
+ destination = null,
+ )
+ }
+
+ private inline fun styled(style: SpanStyle, content: () -> Unit) {
+ val start = emitter.length
+ content()
+ emitter.addSpan(style, start)
+ }
+
+ /** Styles [content] and marks it literal, as code is. */
+ private inline fun literal(style: SpanStyle, content: () -> Unit) {
+ val start = emitter.length
+ content()
+ emitter.addSpan(style, start)
+ emitter.addAnnotation(AnnotationTagLiteral, "", start)
+ }
+
+ private fun ASTNode.text(): CharSequence = getTextInNode(source)
+}
+
+/** Appends source text with its escapes and character references resolved. */
+private fun MarkdownEmitter.appendText(value: CharSequence) {
+ value.toString().resolveMarkdownText().split('\n').forEachIndexed { index, line ->
+ if (index > 0) appendLineBreak()
+ append(line)
+ }
+}
+
+/**
+ * Resolves the backslash escapes and character references the parser leaves in place, in a link's
+ * destination as much as in the text.
+ */
+private fun String.resolveMarkdownText(): String = buildString {
+ val source = this@resolveMarkdownText
+ var index = 0
+ while (index < source.length) {
+ val char = source[index]
+ val next = source.getOrNull(index + 1)
+ val reference = if (char == '&') source.characterReferenceAt(index) else null
+ when {
+ char == '\\' && next != null && next in EscapablePunctuation -> {
+ append(next)
+ index += 2
+ }
+
+ reference != null -> {
+ append(reference.first)
+ index += reference.second
+ }
+
+ else -> {
+ append(char)
+ index++
+ }
+ }
+ }
+}
+
+/**
+ * Decodes the character reference at [start] into its text and length, or null when what follows
+ * the `&` is not one. Only the numeric forms and the named ones below are decoded.
+ */
+private fun CharSequence.characterReferenceAt(start: Int): Pair? {
+ // Bounded, so that a message full of ampersands does not scan its own tail for each one.
+ val limit = minOf(length, start + 1 + MaxCharacterReferenceLength)
+ val semicolon = (start + 1 until limit).firstOrNull { this[it] == ';' } ?: return null
+ val body = subSequence(start + 1, semicolon).toString()
+ if (body.isEmpty()) return null
+ val length = semicolon - start + 1
+ NamedCharacterReferences[body]?.let { return it to length }
+ if (!body.startsWith("#")) return null
+ val codePoint = when {
+ body.startsWith("#x", ignoreCase = true) -> body.drop(2).toIntOrNull(radix = 16)
+ else -> body.drop(1).toIntOrNull()
+ } ?: return null
+ // An invalid code point, a surrogate included, becomes the replacement character.
+ val invalid = codePoint <= 0 ||
+ codePoint > Character.MAX_CODE_POINT ||
+ codePoint in MinSurrogate..MaxSurrogate
+ if (invalid) return ReplacementCharacter to length
+ return String(Character.toChars(codePoint)) to length
+}
+
+/**
+ * Turns a link destination into something openable, or null when it is not. A fragment or a path
+ * only means something inside a document, and a URL invented from one fails when tapped.
+ *
+ * A dotted destination with no path is taken for a host, so `getstream.io` links. A file name like
+ * `readme.md` cannot be told apart and links too, as it does in the View-based and iOS kits.
+ */
+private fun String.toOpenableUrl(): String? {
+ val destination = removeSurrounding("<", ">").trim()
+ return when {
+ destination.isEmpty() || destination.any(Char::isWhitespace) -> null
+ destination.hasOpenableScheme() -> destination.lowercaseScheme()
+ destination.contains('@') && !destination.contains('/') -> "mailto:$destination"
+ // Also reached by a host carrying a port, which reads as a scheme above. HostPattern is
+ // anchored on a dotted host, so a hostile scheme cannot match here.
+ HostPattern.containsMatchIn(destination) -> "https://$destination"
+ else -> null
+ }
+}
+
+/**
+ * A tapped link is handed to the system, so only a scheme a message has business carrying is
+ * annotated. Otherwise text reading as ordinary prose could open a `javascript:` or `intent://`
+ * target, or deep link into the host app.
+ */
+private fun String.hasOpenableScheme(): Boolean = OpenableSchemes.any { scheme ->
+ // A destination that is only a scheme has nothing to open, so it is not a link.
+ startsWith(scheme, ignoreCase = true) && length > scheme.length
+}
+
+/** Android matches an intent filter's scheme case-sensitively, so it has to be lowercase. */
+private fun String.lowercaseScheme(): String {
+ val separator = indexOf(':')
+ return substring(0, separator).lowercase() + substring(separator)
+}
+
+/**
+ * Both spellings of a hard break: the marker left by trailing spaces or a backslash, and the tag.
+ * Either absorbs the line feed it sits on, so ending a line with one breaks it once.
+ */
+private fun ASTNode.isHardBreak(source: String): Boolean = when (type) {
+ MarkdownTokenTypes.HARD_LINE_BREAK -> true
+ MarkdownTokenTypes.HTML_TAG -> getTextInNode(source).toString().isLineBreakTag()
+ else -> false
+}
+
+private fun String.isLineBreakTag(): Boolean = LineBreakTagPattern.matches(trim())
+
+private fun ASTNode.isWhitespace(): Boolean = type == MarkdownTokenTypes.WHITE_SPACE
+
+private val ItalicSpan = SpanStyle(fontStyle = FontStyle.Italic)
+private val BoldSpan = SpanStyle(fontWeight = FontWeight.Bold)
+private val StrikethroughSpan = SpanStyle(textDecoration = TextDecoration.LineThrough)
+
+/** A block sitting inside a list item is set in from the item's text. */
+private const val BlockInItemIndent = " "
+
+private const val UnorderedListMarker = "•"
+
+/** Blocks are separated by at most a blank line, however many breaks the source holds. */
+private const val MaxBlockBreaks = 2
+
+private val OpenableSchemes = listOf("http://", "https://", "mailto:", "tel:")
+private val HostPattern = Regex("^[\\w\\-]+(\\.[\\w\\-]+)+")
+private val LineBreakTagPattern = Regex("
", RegexOption.IGNORE_CASE)
+private const val EscapablePunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+private const val MaxCharacterReferenceLength = 32
+private const val ReplacementCharacter = "\uFFFD"
+private const val MinSurrogate = 0xD800
+private const val MaxSurrogate = 0xDFFF
+
+private val NamedCharacterReferences = mapOf(
+ "amp" to "&",
+ "lt" to "<",
+ "gt" to ">",
+ "quot" to "\"",
+ "apos" to "'",
+ "nbsp" to "\u00A0",
+)
+
+private val HeadingContentTypes = setOf(MarkdownTokenTypes.ATX_CONTENT, MarkdownTokenTypes.SETEXT_CONTENT)
+private val HeadingMarkerTypes = setOf(
+ MarkdownTokenTypes.ATX_HEADER,
+ MarkdownTokenTypes.SETEXT_1,
+ MarkdownTokenTypes.SETEXT_2,
+)
+private val EmphasisMarkerTypes = setOf(MarkdownTokenTypes.EMPH, GFMTokenTypes.TILDE)
+private val LinkLabelMarkerTypes = setOf(MarkdownTokenTypes.LBRACKET, MarkdownTokenTypes.RBRACKET)
+private val AutolinkMarkerTypes = setOf(MarkdownTokenTypes.LT, MarkdownTokenTypes.GT)
+private val QuoteMarkers = Regex("^(?:> ?)+")
+private val ImageLinkTypes = setOf(
+ MarkdownElementTypes.INLINE_LINK,
+ MarkdownElementTypes.FULL_REFERENCE_LINK,
+ MarkdownElementTypes.SHORT_REFERENCE_LINK,
+)
+
+/** CommonMark strips the indentation that declared an indented code block, in either spelling. */
+private fun String.stripCodeIndent(): String = when {
+ startsWith(IndentedCodeSpaces) -> removePrefix(IndentedCodeSpaces)
+ else -> removePrefix("\t")
+}
+
+private const val IndentedCodeSpaces = " "
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextClickableTagTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextClickableTagTest.kt
new file mode 100644
index 00000000000..28702b80e40
--- /dev/null
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/MessageTextClickableTagTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.components.messages
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.buildAnnotatedString
+import io.getstream.chat.android.compose.ui.util.AnnotationTagBlockQuote
+import io.getstream.chat.android.compose.ui.util.AnnotationTagEmail
+import io.getstream.chat.android.compose.ui.util.AnnotationTagLiteral
+import io.getstream.chat.android.compose.ui.util.AnnotationTagMention
+import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.jupiter.api.Test
+
+internal class MessageTextClickableTagTest {
+
+ @Test
+ fun `treats links, emails and mentions as clickable`() {
+ listOf(AnnotationTagUrl, AnnotationTagEmail, AnnotationTagMention).forEach { tag ->
+ range(tag).isClickableTag() shouldBeEqualTo true
+ }
+ }
+
+ @Test
+ fun `treats the markers markdown leaves behind as not clickable`() {
+ listOf(AnnotationTagBlockQuote, AnnotationTagLiteral).forEach { tag ->
+ range(tag).isClickableTag() shouldBeEqualTo false
+ }
+ }
+
+ @Test
+ fun `resolves a tap inside a quote to the link, not the quote's depth`() {
+ // A quote's annotation spans its whole text and holds the depth, and it is recorded before
+ // the entity pass finds the link, so an unfiltered lookup answers with the depth.
+ val text = buildAnnotatedString {
+ append("see https://getstream.io")
+ addStringAnnotation(AnnotationTagBlockQuote, "1", 0, length)
+ addStringAnnotation(AnnotationTagUrl, "https://getstream.io", 4, length)
+ }
+ val position = text.text.indexOf("https")
+
+ val annotations = text.getStringAnnotations(0, text.length)
+ val resolved = annotations.firstOrNull {
+ it.isClickableTag() && position in it.start until it.end
+ }
+
+ resolved?.tag shouldBeEqualTo AnnotationTagUrl
+ resolved?.item shouldBeEqualTo "https://getstream.io"
+ // What the lookup used to return.
+ annotations.firstOrNull { position in it.start until it.end }
+ ?.tag shouldBeEqualTo AnnotationTagBlockQuote
+ }
+
+ @Test
+ fun `does not answer a tap one past the end of an annotation`() {
+ val text = buildAnnotatedString {
+ append("hi https://getstream.io there")
+ addStringAnnotation(AnnotationTagUrl, "https://getstream.io", 3, 23)
+ }
+
+ val annotations = text.getStringAnnotations(0, text.length)
+ annotations.firstOrNull { it.isClickableTag() && 23 in it.start until it.end } shouldBeEqualTo null
+ }
+
+ private fun range(tag: String) = AnnotatedString.Range(item = "value", start = 0, end = 1, tag = tag)
+}
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownMessageTextFormatterTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownMessageTextFormatterTest.kt
new file mode 100644
index 00000000000..7b84da80646
--- /dev/null
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownMessageTextFormatterTest.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import io.getstream.chat.android.compose.ui.theme.StreamTypography
+import io.getstream.chat.android.models.Message
+import io.getstream.chat.android.models.User
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.jupiter.api.Test
+
+internal class MarkdownMessageTextFormatterTest {
+
+ private val formatter = MarkdownMessageTextFormatter(
+ autoTranslationEnabled = false,
+ typography = StreamTypography.defaultTypography(),
+ styles = MarkdownStyles(
+ headings = listOf(30, 26, 22, 18, 16, 14).map { SpanStyle(fontSize = it.sp) },
+ codeSpan = SpanStyle(fontFamily = FontFamily.Monospace),
+ codeBlock = SpanStyle(fontFamily = FontFamily.Monospace),
+ blockQuote = SpanStyle(color = Color.Gray),
+ ),
+ textStyle = { _, _ -> TextStyle(color = Color.Black) },
+ linkStyle = { TextStyle(color = Color.Blue) },
+ mentionColor = { Color.Unspecified },
+ builder = null,
+ )
+
+ private val currentUser = User(id = "me")
+
+ @Test
+ fun `highlights a mention alongside markdown`() {
+ val mentioned = User(id = "u1", name = "Martin")
+ val message = message(text = "**hey** @Martin", mentionedUsers = listOf(mentioned))
+
+ val result = formatter.format(message, currentUser)
+
+ result.text shouldBeEqualTo "hey @Martin"
+ result.annotation(UserMentionTag, "@Martin") shouldBeEqualTo "Martin"
+ result.spanAt("hey")?.fontWeight shouldBeEqualTo FontWeight.Bold
+ }
+
+ @Test
+ fun `highlights a mention whose name is itself emphasised`() {
+ val mentioned = User(id = "u1", name = "Martin")
+ val message = message(text = "hey @**Martin**", mentionedUsers = listOf(mentioned))
+
+ val result = formatter.format(message, currentUser)
+
+ result.text shouldBeEqualTo "hey @Martin"
+ result.annotation(UserMentionTag, "@Martin") shouldBeEqualTo "Martin"
+ result.spanAt("Martin")?.fontWeight shouldBeEqualTo FontWeight.Bold
+ }
+
+ @Test
+ fun `linkifies a bare url after markdown has been rendered`() {
+ val result = formatter.format(message(text = "see *this*: https://getstream.io"), currentUser)
+
+ result.text shouldBeEqualTo "see this: https://getstream.io"
+ result.annotation(AnnotationTagUrl, "https://getstream.io") shouldBeEqualTo "https://getstream.io"
+ }
+
+ @Test
+ fun `keeps the markdown destination when the link label looks like a url`() {
+ val message = message(text = "[https://text-link.com](https://real-link.com)")
+
+ val result = formatter.format(message, currentUser)
+
+ result.text shouldBeEqualTo "https://text-link.com"
+ result.annotation(AnnotationTagUrl, "https://text-link.com") shouldBeEqualTo "https://real-link.com"
+ }
+
+ @Test
+ fun `styles a markdown link like a detected one`() {
+ val result = formatter.format(message(text = "see [the docs](https://getstream.io)"), currentUser)
+
+ result.spanAt("the docs")?.color shouldBeEqualTo Color.Blue
+ }
+
+ @Test
+ fun `linkifies an autolink once its brackets are gone`() {
+ val result = formatter.format(message(text = "visit now"), currentUser)
+
+ result.text shouldBeEqualTo "visit https://getstream.io now"
+ result.annotation(AnnotationTagUrl, "https://getstream.io") shouldBeEqualTo "https://getstream.io"
+ }
+
+ @Test
+ fun `does not linkify a url inside a code span`() {
+ val result = formatter.format(message(text = "run `curl https://getstream.io` now"), currentUser)
+
+ result.text shouldBeEqualTo "run curl https://getstream.io now"
+ result.annotation(AnnotationTagUrl, "https://getstream.io") shouldBeEqualTo null
+ }
+
+ @Test
+ fun `does not highlight a mention inside a code span`() {
+ val mentioned = User(id = "u1", name = "Martin")
+ val message = message(text = "see `@Martin` here", mentionedUsers = listOf(mentioned))
+
+ val result = formatter.format(message, currentUser)
+
+ result.text shouldBeEqualTo "see @Martin here"
+ result.annotation(UserMentionTag, "@Martin") shouldBeEqualTo null
+ }
+
+ @Test
+ fun `keeps the markdown styling underneath a link`() {
+ // The link contributes colour only, so markdown styling underneath survives.
+ val bold = formatter.format(message(text = "**[link](https://x.com)**"), currentUser)
+ bold.spanAt("link")?.fontWeight shouldBeEqualTo FontWeight.Bold
+ bold.spanAt("link")?.color shouldBeEqualTo Color.Blue
+
+ val heading = formatter.format(message(text = "# [link](https://x.com)"), currentUser)
+ heading.spanAt("link")?.fontSize shouldBeEqualTo 30.sp
+ heading.spanAt("link")?.color shouldBeEqualTo Color.Blue
+ }
+
+ @Test
+ fun `keeps the markdown styling underneath a detected link`() {
+ // The entity pass styles links it finds itself, and must narrow them the same way.
+ val result = formatter.format(message(text = "# See https://getstream.io"), currentUser)
+
+ result.spanAt("https://getstream.io")?.fontSize shouldBeEqualTo 30.sp
+ result.spanAt("https://getstream.io")?.color shouldBeEqualTo Color.Blue
+ }
+
+ @Test
+ fun `leaves plain text with line breaks untouched`() {
+ val text = "first line\nsecond line"
+
+ formatter.format(message(text = text), currentUser).text shouldBeEqualTo text
+ }
+
+ @Test
+ fun `applies the base text color to the whole message`() {
+ val result = formatter.format(message(text = "# Title\nbody"), currentUser)
+
+ result.spanAt("body")?.color shouldBeEqualTo Color.Black
+ }
+
+ @Test
+ fun `renders the translation when auto translation is on`() {
+ val translating = MarkdownMessageTextFormatter(
+ autoTranslationEnabled = true,
+ typography = StreamTypography.defaultTypography(),
+ styles = MarkdownStyles(
+ headings = List(6) { SpanStyle() },
+ codeSpan = SpanStyle(),
+ codeBlock = SpanStyle(),
+ blockQuote = SpanStyle(),
+ ),
+ textStyle = { _, _ -> TextStyle(color = Color.Black) },
+ linkStyle = { TextStyle(color = Color.Blue) },
+ mentionColor = { Color.Unspecified },
+ builder = null,
+ )
+ val message = message(text = "**hello**").copy(i18n = mapOf("it_text" to "**ciao**"))
+
+ val result = translating.format(message, User(id = "me", language = "it"))
+
+ result.text shouldBeEqualTo "ciao"
+ result.spanAt("ciao")?.fontWeight shouldBeEqualTo FontWeight.Bold
+ }
+
+ private fun message(text: String, mentionedUsers: List = emptyList()) = Message(
+ id = "message-id",
+ cid = "messaging:channel-id",
+ text = text,
+ user = User(id = "other"),
+ mentionedUsers = mentionedUsers,
+ )
+}
+
+/** Mirrors the tag the Compose kit annotates user mentions with. */
+private const val UserMentionTag = "MENTION"
+
+private fun AnnotatedString.annotation(tag: String, substring: String): String? {
+ val start = text.indexOf(substring)
+ if (start < 0) return null
+ return getStringAnnotations(tag, start, start + substring.length).firstOrNull()?.item
+}
+
+private fun AnnotatedString.spanAt(substring: String): SpanStyle? {
+ val start = text.indexOf(substring)
+ if (start < 0) return null
+ val covering = spanStyles.filter { it.start <= start && it.end >= start + substring.length }
+ if (covering.isEmpty()) return null
+ return covering.map { it.item }.reduce { merged, style -> merged.merge(style) }
+}
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownRendererTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownRendererTest.kt
new file mode 100644
index 00000000000..d86a80168f4
--- /dev/null
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownRendererTest.kt
@@ -0,0 +1,430 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextIndent
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import io.getstream.chat.android.compose.ui.util.internal.MarkdownRenderer
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+
+internal class MarkdownRendererTest {
+
+ private val renderer = MarkdownRenderer(TestStyles)
+
+ @ParameterizedTest
+ @MethodSource("renderedTextArguments")
+ fun `renders the text markdown produces`(source: String, expected: String) {
+ // Read back with the paragraph breaks materialised, since a list expresses its line
+ // breaks as paragraphs rather than as line feeds.
+ renderer.render(source).textWithParagraphBreaks() shouldBeEqualTo expected
+ }
+
+ @Test
+ fun `styles bold text`() {
+ val result = renderer.render("say **that** now")
+
+ result.text shouldBeEqualTo "say that now"
+ result.spanAt("that")?.fontWeight shouldBeEqualTo FontWeight.Bold
+ }
+
+ @Test
+ fun `styles italic text`() {
+ val result = renderer.render("say *that* now")
+
+ result.text shouldBeEqualTo "say that now"
+ result.spanAt("that")?.fontStyle shouldBeEqualTo FontStyle.Italic
+ }
+
+ @Test
+ fun `styles bold italic text`() {
+ val result = renderer.render("say ***that*** now")
+
+ result.text shouldBeEqualTo "say that now"
+ result.spanAt("that")?.fontWeight shouldBeEqualTo FontWeight.Bold
+ result.spanAt("that")?.fontStyle shouldBeEqualTo FontStyle.Italic
+ }
+
+ @Test
+ fun `styles strikethrough text`() {
+ val result = renderer.render("say ~~that~~ now")
+
+ result.text shouldBeEqualTo "say that now"
+ result.spanAt("that")?.textDecoration shouldBeEqualTo TextDecoration.LineThrough
+ }
+
+ @Test
+ fun `styles an inline code span`() {
+ val result = renderer.render("call `render()` first")
+
+ result.text shouldBeEqualTo "call render() first"
+ result.spanAt("render()")?.fontFamily shouldBeEqualTo FontFamily.Monospace
+ }
+
+ @Test
+ fun `styles a fenced code block and keeps its line breaks`() {
+ val result = renderer.render("before\n\n```kotlin\nval a = 1\n\nval b = 2\n```")
+
+ result.text shouldBeEqualTo "before\nval a = 1\n\nval b = 2"
+ result.spanAt("val a = 1")?.fontFamily shouldBeEqualTo FontFamily.Monospace
+ }
+
+ @Test
+ fun `styles a heading per level`() {
+ val levels = (1..6).map { level ->
+ val marker = "#".repeat(level)
+ renderer.render("$marker Title").spanAt("Title")?.fontSize
+ }
+
+ levels shouldBeEqualTo listOf(30.sp, 26.sp, 22.sp, 18.sp, 16.sp, 14.sp)
+ }
+
+ @Test
+ fun `indents nested list items`() {
+ val result = renderer.render("- one\n - two\n - three")
+
+ result.textWithParagraphBreaks() shouldBeEqualTo "• one\n• two\n• three"
+ // One paragraph per item, indented by its level, so a wrapped line stays under its marker.
+ result.paragraphStyles.map { it.item.textIndent } shouldBeEqualTo listOf(
+ TextIndent(firstLine = 0.em, restLine = 0.em),
+ TextIndent(firstLine = 3.em, restLine = 3.em),
+ TextIndent(firstLine = 6.em, restLine = 6.em),
+ )
+ }
+
+ @Test
+ fun `indents a quote and marks its depth, leaving its rail to be drawn`() {
+ val result = renderer.render("> quoted")
+
+ result.paragraphStyles.single().item.textIndent shouldBeEqualTo TextIndent(5.em, 5.em)
+ result.quoteDepths() shouldBeEqualTo listOf("1")
+ }
+
+ @Test
+ fun `stacks the indent and the depth of a nested quote`() {
+ val result = renderer.render("> outer\n> > inner")
+
+ result.paragraphStyles.map { it.item.textIndent } shouldBeEqualTo listOf(
+ TextIndent(5.em, 5.em),
+ TextIndent(10.em, 10.em),
+ )
+ result.quoteDepths() shouldBeEqualTo listOf("1", "2")
+ }
+
+ @Test
+ fun `separates two blocks with a paragraph break rather than a second line feed`() {
+ val result = renderer.render("- one\n\nafter")
+
+ result.text shouldBeEqualTo "• one\nafter"
+ result.paragraphStyles.map { it.start to it.end } shouldBeEqualTo listOf(0 to 6, 6 to 11)
+ }
+
+ @Test
+ fun `marks a quote's whole span, so its rail runs through the gap between its paragraphs`() {
+ val result = renderer.render("> one\n>\n> two")
+
+ val quote = result.getStringAnnotations(AnnotationTagBlockQuote, 0, result.length).single()
+ quote.start to quote.end shouldBeEqualTo (0 to result.length)
+ }
+
+ @Test
+ fun `keeps the ordinals of an ordered list`() {
+ renderer.render("1. one\n1. two\n1. three")
+ .textWithParagraphBreaks() shouldBeEqualTo "1. one\n2. two\n3. three"
+ }
+
+ @Test
+ fun `annotates an inline link with its destination`() {
+ val result = renderer.render("see [the docs](https://getstream.io/chat) now")
+
+ result.text shouldBeEqualTo "see the docs now"
+ result.urlAt("the docs") shouldBeEqualTo "https://getstream.io/chat"
+ }
+
+ @Test
+ fun `makes a schemeless link destination absolute`() {
+ val result = renderer.render("[link](getstream.io)")
+
+ result.urlAt("link") shouldBeEqualTo "https://getstream.io"
+ }
+
+ @Test
+ fun `keeps the destination of a link whose label is itself a url`() {
+ val result = renderer.render("[https://text-link.com](https://real-link.com)")
+
+ result.text shouldBeEqualTo "https://text-link.com"
+ result.urlAt("https://text-link.com") shouldBeEqualTo "https://real-link.com"
+ }
+
+ @Test
+ fun `renders an image as its alt text, since images cannot be drawn`() {
+ renderer.render("").text shouldBeEqualTo "alt text"
+ }
+
+ @Test
+ fun `renders a reference image as its alt text too`() {
+ renderer.render("![alt][d]\n\n[d]: https://x.com/a.png").text shouldBeEqualTo "alt"
+ }
+
+ @ParameterizedTest
+ @MethodSource("unopenableDestinations")
+ fun `does not annotate a destination that cannot be opened`(destination: String) {
+ val result = renderer.render("[label]($destination)")
+
+ result.text shouldBeEqualTo "label"
+ result.urlAt("label") shouldBeEqualTo null
+ }
+
+ @ParameterizedTest
+ @MethodSource("hostileSchemes")
+ fun `does not annotate a scheme a message has no business carrying`(destination: String) {
+ val result = renderer.render("[tap]($destination)")
+
+ result.text shouldBeEqualTo "tap"
+ result.urlAt("tap") shouldBeEqualTo null
+ }
+
+ @ParameterizedTest
+ @MethodSource("openableSchemes")
+ fun `annotates a scheme a message may carry`(destination: String) {
+ renderer.render("[tap]($destination)").urlAt("tap") shouldBeEqualTo destination
+ }
+
+ @Test
+ fun `links a destination carrying a port`() {
+ renderer.render("[label](example.com:8080)").urlAt("label") shouldBeEqualTo
+ "https://example.com:8080"
+ }
+
+ @Test
+ fun `resolves escapes and references inside a destination`() {
+ renderer.render("[x](https://a.com?a=1&b=2)").urlAt("x") shouldBeEqualTo
+ "https://a.com?a=1&b=2"
+ renderer.render("[x](https://a.com/a\\_b)").urlAt("x") shouldBeEqualTo "https://a.com/a_b"
+ }
+
+ @Test
+ fun `falls back to plain text when a document cannot be rendered`() {
+ // Nesting this deep exhausts the stack while parsing.
+ val source = ">".repeat(2000) + " x"
+
+ renderer.render(source).text shouldBeEqualTo source
+ }
+
+ @Test
+ fun `resolves a full reference link`() {
+ val result = renderer.render("see [the docs][d] now\n\n[d]: https://getstream.io")
+
+ result.text shouldBeEqualTo "see the docs now"
+ result.urlAt("the docs") shouldBeEqualTo "https://getstream.io"
+ }
+
+ @Test
+ fun `resolves a short reference link`() {
+ val result = renderer.render("see [d] now\n\n[d]: getstream.io")
+
+ result.text shouldBeEqualTo "see d now"
+ result.urlAt("d") shouldBeEqualTo "https://getstream.io"
+ }
+
+ @Test
+ fun `leaves a reference link with no definition as written`() {
+ renderer.render("see [the docs][nope] now").text shouldBeEqualTo "see [the docs][nope] now"
+ }
+
+ @Test
+ fun `does not hang on pathological emphasis markers`() {
+ // Regression input taken from the iOS SDK, which once hung on it.
+ val source = "**~*~~~*~*~**~*~* h e a r d ***~*~*~**~*~~~*"
+
+ renderer.render(source).text.isNotEmpty() shouldBeEqualTo true
+ }
+
+ // JUnit resolves these by name for @MethodSource, so nothing references them in code.
+ companion object {
+
+ @JvmStatic
+ @Suppress("unused")
+ fun unopenableDestinations(): List = listOf("#section", "/docs/page", "foo bar")
+
+ @JvmStatic
+ @Suppress("unused")
+ fun hostileSchemes(): List = listOf(
+ "javascript:alert(1)",
+ "intent://scan/#Intent;scheme=zxing;end",
+ "file:///data/data/x",
+ "myapp://reset?token=1",
+ "unknown-scheme:whatever",
+ )
+
+ @JvmStatic
+ @Suppress("unused")
+ fun openableSchemes(): List = listOf(
+ "http://x.com",
+ "https://x.com",
+ "mailto:a@b.com",
+ "tel:+123",
+ )
+
+ @JvmStatic
+ @Suppress("unused")
+ fun renderedTextArguments(): List = textArguments() + blockArguments()
+
+ private fun textArguments(): List = listOf(
+ Arguments.of("plain text", "plain text"),
+ // Blocks are separated by the breaks the author wrote, no more and no fewer.
+ Arguments.of("Shopping list:\n- milk\n- eggs", "Shopping list:\n• milk\n• eggs"),
+ Arguments.of("intro\n# Heading", "intro\nHeading"),
+ Arguments.of("# Heading\n\nbody", "Heading\n\nbody"),
+ // A block inside a list item or quote keeps its own indentation; only the quote
+ // markers of a continuation line go.
+ Arguments.of("> \n> x\n>
", "\n x\n
"),
+ // A soft break stays a line break, or enabling markdown would join lines.
+ Arguments.of("first\nsecond", "first\nsecond"),
+ Arguments.of("first\n\nsecond", "first\n\nsecond"),
+ // Hard breaks, in their three spellings.
+ Arguments.of("first \nsecond", "first\nsecond"),
+ Arguments.of("first\\\nsecond", "first\nsecond"),
+ Arguments.of("first
second", "first\nsecond"),
+ // A tag ending a line absorbs that line's feed, exactly as trailing spaces do.
+ Arguments.of("first
\nsecond", "first\nsecond"),
+ Arguments.of("# Title\nbody", "Title\nbody"),
+ Arguments.of("> quoted", "quoted"),
+ // A soft break inside a quote is one block, and every line of it is marked.
+ Arguments.of("> quoted\n> continued", "quoted\ncontinued"),
+ // A blank line ends a quote, so this is two of them, kept apart.
+ Arguments.of("> first\n\n> second", "first\n\nsecond"),
+ // A quote holding two paragraphs marks the blank line between them too.
+ Arguments.of("> one\n>\n> two", "one\n\ntwo"),
+ // A hard break inside a quote opens exactly one new marked line.
+ Arguments.of("> one \n> two", "one\ntwo"),
+ // Nesting stacks the markers.
+ Arguments.of("> outer\n> > inner", "outer\ninner"),
+ Arguments.of("- one\n- two", "• one\n• two"),
+ Arguments.of("---\nafter", "***\nafter"),
+ // Escapes are resolved, and a backslash that escapes nothing is left alone.
+ Arguments.of("5 \\* 3", "5 * 3"),
+ Arguments.of("C:\\path\\to", "C:\\path\\to"),
+ // Autolink brackets are syntax; the entity pass linkifies the bare URL.
+ Arguments.of("visit now", "visit https://getstream.io now"),
+ Arguments.of("mail now", "mail a@b.com now"),
+ // Character references are resolved, named and numeric alike.
+ Arguments.of("a & b <c>", "a & b "),
+ Arguments.of("a & b & c", "a & b & c"),
+ // Anything that only looks like one is left as typed.
+ Arguments.of("a ¬real; b", "a ¬real; b"),
+ // Code content is literal, so a reference inside it stays as written.
+ Arguments.of("`a & b`", "a & b"),
+ )
+
+ private fun blockArguments(): List = listOf(
+ // An item whose only content is a block keeps it on the marker's line.
+ Arguments.of("- # H", "• H"),
+ Arguments.of("- > quoted", "• \nquoted"),
+ Arguments.of("- ```\n x\n ```", "• x"),
+ Arguments.of("- - a", "• \n• a"),
+ Arguments.of("1. # H\n1. next", "1. H\n2. next"),
+ // A second block starts its own line, indented under the item's text.
+ Arguments.of("- item\n\n # H\n- next", "• item\n H\n• next"),
+ Arguments.of("- item\n\n > q\n- next", "• item\nq\n• next"),
+ // Content following a nested list stays indented, and the next item still gets a line.
+ Arguments.of("- a\n - b\n\n more\n- c", "• a\n• b\n more\n• c"),
+ // A code block inside a quote keeps the marker on every line.
+ Arguments.of("> ```\n> one\n> two\n> ```", "one\ntwo"),
+ // An indented code block loses the indentation that declared it.
+ Arguments.of(" one\n two", "one\ntwo"),
+ // A table is still a block, so what follows it starts on a new line.
+ Arguments.of("| a | b |\n| - | - |\n\nafter", "| a | b |\n| - | - |\n\nafter"),
+ // Carriage returns never survive into the output.
+ Arguments.of("a\r\n\r\nb", "a\n\nb"),
+ Arguments.of("a\r\nb", "a\nb"),
+ // A heading keeps neither the space before its text nor the one after it.
+ Arguments.of("# H \nnext", "H\nnext"),
+ Arguments.of("# H #\nnext", "H\nnext"),
+ // Two breaks in the source stay two breaks.
+ Arguments.of("a
b", "a\n\nb"),
+ // An HTML block is a block, so what follows it starts on a new line.
+ Arguments.of("x
\n\nafter", "x
\n\nafter"),
+ // A document that renders to nothing falls back to what was typed.
+ Arguments.of("[d]: https://getstream.io", "[d]: https://getstream.io"),
+ // A checkbox belongs beside the marker rather than pushing the item onto a new line.
+ Arguments.of("- [x] done", "• [x] done"),
+ Arguments.of("- [ ] todo", "• [ ] todo"),
+ // Tab indentation declares an indented code block just as four spaces do.
+ Arguments.of("\tone\n\ttwo", "one\ntwo"),
+ // Every line of a block inside a list item is indented, not only the first.
+ Arguments.of("- item\n\n one\n two", "• item\n one\n two"),
+ // The specification turns a line ending inside a code span into a space.
+ Arguments.of("> `a\n> b`", "a b"),
+ // A reference to an invalid code point becomes the replacement character.
+ Arguments.of("a b", "a \uFFFD b"),
+ Arguments.of("a b", "a \uFFFD b"),
+ // A link or image with an empty label keeps its source, rather than disappearing.
+ Arguments.of("see [](https://x.com) here", "see [](https://x.com) here"),
+ Arguments.of("", ""),
+ // Only the delimiter runs are syntax, so a backtick between them is content.
+ Arguments.of("``a `b` c``", "a `b` c"),
+ // Verbatim source drops the quote markers of the lines it continues on.
+ Arguments.of("> \n> x\n>
", "\nx\n
"),
+ Arguments.of("> | a |\n> | - |", "| a |\n| - |"),
+ // A message of only whitespace is still text the sender typed.
+ Arguments.of(" ", " "),
+ // Unsupported constructs keep their source text so nothing is lost.
+ Arguments.of("| a | b |\n| --- | --- |\n| 1 | 2 |", "| a | b |\n| --- | --- |\n| 1 | 2 |"),
+ )
+ }
+}
+
+/** Sorted, because a quote is marked once its content is walked, so the innermost lands first. */
+private fun AnnotatedString.quoteDepths(): List =
+ getStringAnnotations(AnnotationTagBlockQuote, 0, length).map { it.item }.sorted()
+
+private val TestStyles = MarkdownStyles(
+ headings = listOf(30, 26, 22, 18, 16, 14).map { SpanStyle(fontSize = it.sp) },
+ codeSpan = SpanStyle(fontFamily = FontFamily.Monospace),
+ codeBlock = SpanStyle(fontFamily = FontFamily.Monospace),
+ blockQuote = SpanStyle(color = Color.Gray),
+ listIndent = 3.em,
+ blockQuoteIndent = 5.em,
+ thematicBreak = "***",
+)
+
+/** The merged span covering [substring], or null when it carries no style of its own. */
+private fun AnnotatedString.spanAt(substring: String): SpanStyle? {
+ val start = text.indexOf(substring)
+ if (start < 0) return null
+ val covering = spanStyles.filter { it.start <= start && it.end >= start + substring.length }
+ if (covering.isEmpty()) return null
+ return covering.map { it.item }.reduce { merged, style -> merged.merge(style) }
+}
+
+private fun AnnotatedString.urlAt(substring: String): String? {
+ val start = text.indexOf(substring)
+ if (start < 0) return null
+ return getStringAnnotations(AnnotationTagUrl, start, start + substring.length).firstOrNull()?.item
+}
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownRtlSnapshotTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownRtlSnapshotTest.kt
new file mode 100644
index 00000000000..999e9e39637
--- /dev/null
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownRtlSnapshotTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Paparazzi
+import com.android.ide.common.rendering.api.SessionParams
+import io.getstream.chat.android.compose.ui.PaparazziComposeTest
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.models.Message
+import io.getstream.chat.android.models.User
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Renders markdown in a right-to-left layout, where a quote's rail has to be mirrored to the side
+ * its text is set in from. Lists need nothing: their indent and their marker are both laid out
+ * from the start edge already.
+ *
+ * Only right-to-left content, and the layout direction is provided rather than left to the
+ * device's locale. Paparazzi resolves an unspecified text direction from the layout alone, where a
+ * device resolves it from the content of each paragraph, so a message mixing the two directions
+ * renders here in a way no device would show and cannot be covered by a snapshot.
+ */
+internal class MarkdownRtlSnapshotTest : PaparazziComposeTest {
+
+ @get:Rule
+ override val paparazzi: Paparazzi = Paparazzi(
+ deviceConfig = DeviceConfig.PIXEL_2.copy(screenHeight = 1200),
+ renderingMode = SessionParams.RenderingMode.SHRINK,
+ )
+
+ @Test
+ fun `right to left content`() = snapshotWithDarkMode {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ MarkdownText(
+ """
+ > هذا اقتباس طويل بما يكفي لكي يلتف على سطر ثانٍ ونرى أين ينتهي الخط
+ >
+ > وهذه فقرة ثانية من الاقتباس نفسه
+
+ - عنصر قائمة طويل بما يكفي لكي يلتف على سطر ثانٍ ونرى مكانه
+ - عنصر متداخل طويل أيضاً لكي يلتف على سطر ثانٍ
+ """.trimIndent(),
+ )
+ }
+ }
+
+ @Composable
+ private fun MarkdownText(text: String) {
+ val formatter = MessageTextFormatter.markdownFormatter(
+ autoTranslationEnabled = false,
+ typography = ChatTheme.typography,
+ colors = ChatTheme.colors,
+ )
+ val message = Message(id = "id", cid = "messaging:cid", text = text, user = User(id = "other"))
+ val styled = formatter.format(message, currentUser = User(id = "me"))
+ val layout = remember(styled) { mutableStateOf(null) }
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ .blockQuoteRails(
+ annotations = styled.getStringAnnotations(0, styled.length),
+ layout = layout::value,
+ color = ChatTheme.colors.textLowEmphasis,
+ indentPerDepth = MarkdownStyles.BlockQuoteIndent,
+ ),
+ text = styled,
+ style = ChatTheme.typography.body,
+ onTextLayout = { layout.value = it },
+ )
+ }
+}
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownSnapshotTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownSnapshotTest.kt
new file mode 100644
index 00000000000..adb656189b3
--- /dev/null
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/MarkdownSnapshotTest.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.ui.util
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.unit.dp
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Paparazzi
+import com.android.ide.common.rendering.api.SessionParams
+import io.getstream.chat.android.compose.ui.PaparazziComposeTest
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.models.Message
+import io.getstream.chat.android.models.User
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Renders every supported markdown construct through the markdown formatter, in light and
+ * dark mode, so that changes to the styling or to the block layout are visible in review.
+ *
+ * Span-level styling is asserted precisely in [MarkdownRendererTest]; this covers what only a
+ * render shows - indentation, line spacing, and how far backgrounds reach.
+ */
+internal class MarkdownSnapshotTest : PaparazziComposeTest {
+
+ @get:Rule
+ override val paparazzi: Paparazzi = Paparazzi(
+ // SHRINK trims to the content, so the device only has to be tall enough not to clip.
+ deviceConfig = DeviceConfig.PIXEL_2.copy(screenHeight = TallEnoughForEveryConstruct),
+ renderingMode = SessionParams.RenderingMode.SHRINK,
+ )
+
+ @Test
+ fun `wrapping content`() = snapshotWithDarkMode {
+ MarkdownText(
+ """
+ - a list item long enough that it wraps onto a second line and shows where that lands
+ - short
+ - a nested item long enough to wrap, to see where its continuation lands
+ - a deeper item, again long enough that it has to wrap onto a second line
+ - back at the top level, and long enough to wrap so its continuation shows too
+
+ > a quoted line long enough that it wraps, to see whether the marker survives the wrap
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun `quote inside a list item`() = snapshotWithDarkMode {
+ MarkdownText(
+ """
+ - > a quote as the whole item, long enough that it wraps onto a second line
+ - an ordinary item
+
+ > a quote after a paragraph, also long enough to wrap onto two lines
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun `every supported construct`() = snapshotWithDarkMode {
+ MarkdownText(
+ """
+ Plain, **bold**, *italic*, ***both***, ~~struck~~ and `code()`.
+ A [link](https://getstream.io) and a bare https://getstream.io too.
+ A [reference][d] link, and  as alt text.
+
+ [d]: https://getstream.io
+
+ # Heading 1
+ ## Heading 2
+ ### Heading 3
+ #### Heading 4
+ ##### Heading 5
+ ###### Heading 6
+
+ - first
+ - second
+ - nested
+ - deeper
+ 1. one
+ 1. two
+
+ > a quoted line
+ > and its continuation
+ >
+ > a second paragraph, still quoted
+
+ > a separate quote
+
+ > outer, long enough that it wraps onto a second line of its own
+ > > inner, also long enough that it has to wrap onto a second line
+
+ ```kotlin
+ fun main() {
+ println("hi")
+ }
+ ```
+
+ ---
+ after the break
+ """.trimIndent(),
+ )
+ }
+
+ @Composable
+ private fun MarkdownText(text: String) {
+ val formatter = MessageTextFormatter.markdownFormatter(
+ autoTranslationEnabled = false,
+ typography = ChatTheme.typography,
+ colors = ChatTheme.colors,
+ )
+ val message = Message(id = "id", cid = "messaging:cid", text = text, user = User(id = "other"))
+ val styled = formatter.format(message, currentUser = User(id = "me"))
+ // The rails are drawn from the layout, as MessageText draws them, so they show up here too.
+ val layout = remember(styled) { mutableStateOf(null) }
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ .blockQuoteRails(
+ annotations = styled.getStringAnnotations(0, styled.length),
+ layout = layout::value,
+ color = ChatTheme.colors.textLowEmphasis,
+ indentPerDepth = MarkdownStyles.BlockQuoteIndent,
+ ),
+ text = styled,
+ style = ChatTheme.typography.body,
+ onTextLayout = { layout.value = it },
+ )
+ }
+}
+
+private const val TallEnoughForEveryConstruct = 4000
diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownRtlSnapshotTest_right_to_left_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownRtlSnapshotTest_right_to_left_content.png
new file mode 100644
index 00000000000..4245e607a56
Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownRtlSnapshotTest_right_to_left_content.png differ
diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_every_supported_construct.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_every_supported_construct.png
new file mode 100644
index 00000000000..d6d56bd6556
Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_every_supported_construct.png differ
diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_quote_inside_a_list_item.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_quote_inside_a_list_item.png
new file mode 100644
index 00000000000..8406a0214ca
Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_quote_inside_a_list_item.png differ
diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_wrapping_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_wrapping_content.png
new file mode 100644
index 00000000000..c0997b664bf
Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.util_MarkdownSnapshotTest_wrapping_content.png differ