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("![alt text](https://example.com/a.png)").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("![](https://x.com/a.png)", "![](https://x.com/a.png)"), + // 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 ![an image](https://x.com/a.png) 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