Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
78fa627
Add opt-in markdown rendering for Compose message text
gpunto Sep 2, 2026
a066949
Mark every line of a rendered block quote
gpunto Sep 2, 2026
ddbb469
Keep quote markers correct across hard breaks, paragraphs and nesting
gpunto Sep 2, 2026
29ea4e3
Resolve reference links, autolinks, character references and image al…
gpunto Sep 2, 2026
bd7448b
Fix block handling in list items, quoted code, tables and link destin…
gpunto Sep 2, 2026
33bd669
Keep a list marker on the same line as the block that follows it
gpunto Sep 2, 2026
d927098
Correct heading scale, empty renders, literal code and block separation
gpunto Sep 2, 2026
d871b92
Absorb the line feed after a break written as a tag
gpunto Sep 2, 2026
d87a66b
Name the hard break check so its two spellings live in one place
gpunto Sep 2, 2026
f6c1775
Restrict link schemes, contain render failures and fix construct edge…
gpunto Sep 2, 2026
c8d1642
Keep markdown styling under links and stop dropping content from sour…
gpunto Sep 2, 2026
de41c4e
Expose markdown through a formatter factory instead of a public class
gpunto Sep 3, 2026
e6e46a2
Keep markdown styling internal until something needs to override it
gpunto Sep 3, 2026
1bb4e2e
Collapse the repeated link and heading handling
gpunto Sep 3, 2026
63d6c27
Trim the comments and docs down to what the code does not already say
gpunto Sep 3, 2026
66e235e
Stop claiming a markdown flavour the renderer does not implement
gpunto Sep 3, 2026
448696b
Drop the markdown factory parameters nothing has asked for
gpunto Sep 3, 2026
48ed4ca
Put the sample's markdown rendering behind a feature flag
gpunto Sep 3, 2026
53c291d
Take block separation from the source and stop link styling from flat…
gpunto Sep 3, 2026
9c27ae1
Drop the link failure log and bound the character reference search
gpunto Sep 3, 2026
c43c5a3
Scroll the sample's feature flag list now that it outgrows the screen
gpunto Sep 7, 2026
4d0f1f7
Address review feedback on the Compose markdown renderer
gpunto Sep 7, 2026
9842fc9
Hang the wrapped lines of markdown lists and quotes
gpunto Sep 7, 2026
348ad17
Revert "Hang the wrapped lines of markdown lists and quotes"
gpunto Sep 7, 2026
858dbbe
Reapply "Hang the wrapped lines of markdown lists and quotes"
gpunto Sep 7, 2026
6efaa9c
Keep a wrapped list line at the indent of its own level
gpunto Sep 7, 2026
8e57e99
Draw the block quote rail instead of writing it as a marker
gpunto Sep 7, 2026
6051c87
Mirror the block quote rail in a right to left layout
gpunto Sep 7, 2026
c8ad671
Cover only right to left content in the mirrored rail snapshot
gpunto Sep 7, 2026
a1d37b4
Remember the layout result held by the markdown snapshot tests
gpunto Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ androidxTest = "1.5.2"
baseProfile = "1.3.1"
coil = "3.1.0"
composeAccompanist = "0.36.0"
composeMarkdown = "0.5.4"
composeStableMarker = "1.0.5"
constraintLayout = "2.2.0"
coroutines = "1.9.0"
Expand All @@ -60,6 +59,7 @@ kotlinBinaryValidator = "0.16.3"
ksp = "2.2.0-2.0.2"
leakCanary = "2.4"
macroBenchmark = "1.2.3"
markdown = "0.7.3"
markwon = "4.6.2"
materialComponents = "1.12.0"
mockitoKotlin = "5.4.0"
Expand Down Expand Up @@ -160,7 +160,6 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"}
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil"}
coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil"}
compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "composeMarkdown"}
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektPlugin"}
firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx", version.ref = "firebaseAnalytics"}
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics", version.ref = "firebaseCrashlytics"}
Expand All @@ -182,6 +181,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"}
Expand Down
1 change: 0 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ dependencyResolutionManagement {

maven(url = "https://jitpack.io") {
content {
includeModule("com.github.jeziellago", "compose-markdown")
includeModule("com.github.jeziellago", "Markwon")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class CustomSettings(private val context: Context) {
var isLocationSharingEnabled: Boolean by booleanPref(LocationSharing)
var isLocalUnreadCountEnabled: Boolean by booleanPref(LocalUnreadCount)
var isChannelSearchEnabled: Boolean by booleanPref(ChannelSearch)
var isMarkdownEnabled: Boolean by booleanPref(Markdown)

private fun booleanPref(key: String, default: Boolean = false) =
object : ReadWriteProperty<Any?, Boolean> {
Expand All @@ -59,5 +60,6 @@ private const val SystemAttachmentPicker = "system_attachment_picker"
private const val LocationSharing = "location_sharing"
private const val LocalUnreadCount = "local_unread_count"
private const val ChannelSearch = "channel_search"
private const val Markdown = "markdown"

fun Context.customSettings(): CustomSettings = CustomSettings(this)
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ package io.getstream.chat.android.compose.sample.ui

import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import io.getstream.chat.android.compose.sample.data.customSettings
import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.ui.theme.ChatUiConfig
import io.getstream.chat.android.compose.ui.util.MessageTextFormatter

/**
* Sample app wrapper around [ChatTheme] that enables test tags as resource IDs for UIAutomator E2E tests.
Expand All @@ -37,10 +41,21 @@ internal fun SampleChatTheme(
componentFactory: ChatComponentFactory = object : ChatComponentFactory {},
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val settings = remember(context) { context.customSettings() }
Box(modifier = Modifier.semantics { testTagsAsResourceId = true }) {
ChatTheme(
config = config,
componentFactory = componentFactory,
messageTextFormatter = when {
settings.isMarkdownEnabled -> MessageTextFormatter.markdownFormatter(
autoTranslationEnabled = config.translation.enabled,
)

else -> MessageTextFormatter.defaultFormatter(
autoTranslationEnabled = config.translation.enabled,
)
},
content = content,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
Expand Down Expand Up @@ -116,6 +117,7 @@ class CustomLoginActivity : AppCompatActivity() {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(it)
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
verticalArrangement = Arrangement.Center,
Expand Down Expand Up @@ -147,6 +149,9 @@ class CustomLoginActivity : AppCompatActivity() {
var isChannelSearchEnabled by remember {
mutableStateOf(settings.isChannelSearchEnabled)
}
var isMarkdownEnabled by remember {
mutableStateOf(settings.isMarkdownEnabled)
}

val isLoginButtonEnabled = apiKeyText.isNotEmpty() &&
userIdText.isNotEmpty() &&
Expand Down Expand Up @@ -231,6 +236,15 @@ class CustomLoginActivity : AppCompatActivity() {
settings.isChannelSearchEnabled = it
},
),
FeatureFlag(
label = stringResource(R.string.custom_login_flag_markdown_label),
description = stringResource(R.string.custom_login_flag_markdown_description),
value = isMarkdownEnabled,
onValueChange = {
isMarkdownEnabled = it
settings.isMarkdownEnabled = it
},
),
)

CustomLoginInputField(
Expand Down Expand Up @@ -268,8 +282,6 @@ class CustomLoginActivity : AppCompatActivity() {
)
}

Spacer(modifier = Modifier.weight(1f))

CustomLoginButton(
enabled = isLoginButtonEnabled,
onClick = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
<string name="custom_login_flag_local_unread_count_description">Track the unread count locally for channels with read events disabled. Also shows livestream channels in the channel list</string>
<string name="custom_login_flag_channel_search_label">Channel search</string>
<string name="custom_login_flag_channel_search_description">Search for channels instead of messages from the channel list header</string>
<string name="custom_login_flag_markdown_label">Markdown</string>
<string name="custom_login_flag_markdown_description">Render message text as markdown</string>

<!-- Pinned Messages -->
<string name="pinned_messages_title">Pinned Messages</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6765,6 +6765,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/util/Messag
public final class io/getstream/chat/android/compose/ui/util/MessageTextFormatter$Companion {
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/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;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 markdownFormatter (ZZLio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;
}

public final class io/getstream/chat/android/compose/ui/util/MessageUtilsKt {
Expand Down
3 changes: 3 additions & 0 deletions stream-chat-android-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ dependencies {
implementation(libs.coil.network.okhttp)
implementation(libs.coil.video)

// Markdown
implementation(libs.markdown)

// Media3
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.getstream.chat.android.compose.ui.components.messages

import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
Expand Down Expand Up @@ -56,10 +57,13 @@ import io.getstream.chat.android.compose.ui.util.AnnotationTagHereMention
import io.getstream.chat.android.compose.ui.util.AnnotationTagRoleMention
import io.getstream.chat.android.compose.ui.util.AnnotationTagUrl
import io.getstream.chat.android.compose.ui.util.AnnotationTagUserMention
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.isFewEmoji
import io.getstream.chat.android.compose.ui.util.isSingleEmoji
import io.getstream.chat.android.compose.ui.util.senderAwareContentDescription
import io.getstream.chat.android.compose.ui.util.showOriginalTextAsState
import io.getstream.chat.android.compose.ui.util.textWithParagraphBreaks
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.User
import io.getstream.chat.android.models.UserGroup
Expand Down Expand Up @@ -121,19 +125,28 @@ public fun MessageText(
val senderAwareText = senderAwareContentDescription(
isMine = message.isMine(currentUser),
senderName = message.user.name,
content = styledText.text,
content = styledText.textWithParagraphBreaks(),
isReply = message.replyTo != null,
)
// Read inside the draw pass, which runs after the layout that sets it.
val layout = remember(styledText) { mutableStateOf<TextLayoutResult?>(null) }
val textModifier = modifier
.padding(MessageStyling.textPadding)
.semantics { contentDescription = senderAwareText }
.blockQuoteRails(
annotations = annotations,
layout = layout::value,
color = ChatTheme.colors.borderCoreStrong,
indentPerDepth = MarkdownStyles.BlockQuoteIndent,
)
if (annotations.fastAny(AnnotatedString.Range<String>::isInteractiveTag)) {
ClickableText(
modifier = modifier
.padding(MessageStyling.textPadding)
.testTag("Stream_MessageClickableText")
.semantics { contentDescription = senderAwareText },
modifier = textModifier.testTag("Stream_MessageClickableText"),
text = styledText,
style = style,
onLongPress = { onLongItemClick(message) },
isInteractiveAt = annotations::hasInteractiveAt,
onTextLayout = { layout.value = it },
) { position ->
handleAnnotationClick(
annotations = annotations,
Expand All @@ -143,19 +156,23 @@ public fun MessageText(
onMentionClick = onMentionClick,
onUserMentionClick = onUserMentionClick,
fallback = { url ->
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
try {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
} catch (_: ActivityNotFoundException) {
// Nothing guarantees an app exists for the link's scheme, and a tap on one
// must not bring the message list down.
}
},
)
}
} else {
Text(
modifier = modifier
.padding(MessageStyling.textPadding)
modifier = textModifier
.clipToBounds()
.testTag("Stream_MessageText")
.semantics { contentDescription = senderAwareText },
.testTag("Stream_MessageText"),
text = styledText,
style = style,
onTextLayout = { layout.value = it },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,22 @@
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 }

Check warning on line 89 in stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AnnotatedString.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this collection immutable.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AaB7rtgrp8EFLDUULPEj&open=AaB7rtgrp8EFLDUULPEj&pullRequest=6681
if (starts.isEmpty()) return text
return buildString(text.length + starts.size) {
text.forEachIndexed { index, character ->
if (index in starts) append('\n')
append(character)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AnnotatedString.Range<String>>,
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
Comment thread
aleksandar-apostolov marked this conversation as resolved.
// 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<String>): 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
Loading
Loading