Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

else -> MessageTextFormatter.defaultFormatter(
autoTranslationEnabled = ChatApp.autoTranslationEnabled,
isInDarkMode = isInDarkMode,
typography = typography,
shapes = shapes,
colors = colors,
)
}

@Composable
private fun SetupChatTheme() {
val isInDarkMode = isSystemInDarkTheme()
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() &&
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
) {
Expand All @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
<string name="custom_login_hint_user_name">Username (optional)</string>
<string name="custom_login_enable_adaptive_layout">Enable adaptive layout (Experimental)</string>
<string name="custom_login_enable_adaptive_layout_description">Adjust layout based on screen sizes</string>
<string name="custom_login_enable_markdown">Enable markdown</string>
<string name="custom_login_enable_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 @@ -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 {
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 @@ -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)
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 android.net.Uri
import androidx.compose.foundation.gestures.detectTapGestures
Expand All @@ -42,6 +43,8 @@
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
Expand All @@ -67,7 +70,7 @@
*/
@Composable
@Suppress("LongMethod")
public fun MessageText(

Check failure on line 73 in stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageText.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AaCAc0_eUcM4pixfx4bi&open=AaCAc0_eUcM4pixfx4bi&pullRequest=6683
message: Message,
currentUser: User?,
modifier: Modifier = Modifier,
Expand All @@ -89,7 +92,16 @@
}
}

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<TextLayoutResult?>(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 {
Expand All @@ -101,10 +113,7 @@
ChatTheme.otherMessageTheme.textStyle
}
}
if (annotations.fastAny {
it.tag == AnnotationTagUrl || it.tag == AnnotationTagEmail || it.tag == AnnotationTagMention
}
) {
if (annotations.fastAny(AnnotatedString.Range<String>::isClickableTag)) {
ClickableText(
modifier = modifier
.padding(
Expand All @@ -113,21 +122,30 @@
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.
}
}
}
}
Expand All @@ -142,13 +160,28 @@
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<String>.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.
Expand Down
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
// 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