diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index a9d3d15a0..6dfcb12a3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -143,7 +143,9 @@ import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.ui.UndecryptableHint import com.flipcash.shared.chat.ui.undecryptableHint +import com.flipcash.shared.chat.ui.cardLinks import com.flipcash.shared.chat.ui.detectMentions +import com.flipcash.shared.chat.ui.formattedDisplayText import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText import com.flipcash.shared.chat.withinWindows @@ -1231,7 +1233,7 @@ internal class ChatViewModel @AssistedInject constructor( // whether or not the reader ever scrolled to it. val linkableText = enriched.linkableText() val links = linkableText?.let(::detectUrls).orEmpty() - val linkCard = linkCardClassifier.firstCard(links) + val linkCard = linkableText?.let { linkCardClassifier.firstCard(cardLinks(it, links)) } // Detected over the whole text, beside the links, so a handle inside a link // stays the link's. Nothing is looked up until one is tapped. @@ -1439,7 +1441,7 @@ internal class ChatViewModel @AssistedInject constructor( }, ) - is MessageContent.Text -> ChatQuoteSnippet.Text(body.text) + is MessageContent.Text -> ChatQuoteSnippet.Text(formattedDisplayText(body.text)) is MessageContent.Media -> body.toPhotoSnippet(this) @@ -1457,7 +1459,7 @@ internal class ChatViewModel @AssistedInject constructor( is MessageContent.Media -> inner.toPhotoSnippet(this) else -> ChatQuoteSnippet.Text( body.content.filterIsInstance() - .firstOrNull()?.text.orEmpty() + .firstOrNull()?.text?.let(::formattedDisplayText).orEmpty() ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt index 82c16f728..b86b95e4c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt @@ -5,7 +5,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.shared.chat.MessageLinkPrefetch import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.ui.detectUrls +import com.flipcash.shared.chat.ui.cardLinks import com.flipcash.shared.chat.ui.linkableText import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -53,7 +53,7 @@ internal class MessageLinkPrefetcher( .asSequence() .filterNot { it.redacted } .flatMap { it.content.asSequence() } - .mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(detectUrls(it)) } } + .mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(cardLinks(it)) } } .mapNotNull { card -> lookup(card, webAllowed) } .toList() diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index fc6622756..d498e2db7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -117,7 +117,7 @@ private fun MessageContent.previewText( depth: Int = 0, ): String? = when (this) { is MessageContent.Text -> { - val message = text.takeIf { it.isNotEmpty() } + val message = formattedDisplayText(text).takeIf { it.isNotEmpty() } when { message == null -> null sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt index 9e32ad5ee..77942f1b9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt @@ -8,6 +8,18 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle +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.TextUnit +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.isUnspecified +import com.flipcash.libs.textformat.FormatStyle +import com.flipcash.libs.textformat.RangeKind +import com.flipcash.libs.textformat.StyledSpan import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -40,25 +52,42 @@ internal object MentionPill { } /** - * A text bubble's body with its links and mentions in place, and where each handle's letters sit - * in it. + * A text bubble's body with its formatting, links and mentions in place, and where each handle's + * letters sit in it. * - * The body is not [text] verbatim: each mention is bracketed by a placeholder [MentionPill.spacing] - * wide, which is what makes room for the pill without drawing over the words beside it. iOS kerns - * the neighbouring characters for the same room; Compose has no per-character kern, and a - * placeholder of known width is the nearest thing. Its stand-in character is a no-break space, so - * the line can still break before the leading one but never between it and the `@`, nor between - * the handle and the trailing one. + * The body is not the message text verbatim. The parser's consumed markers are gone, and each + * mention is bracketed by a placeholder [MentionPill.spacing] wide, which is what makes room for + * the pill without drawing over the words beside it. iOS kerns the neighbouring characters for the + * same room; Compose has no per-character kern, and a placeholder of known width is the nearest + * thing. Its stand-in character is a no-break space, so the line can still break before the + * leading one but never between it and the `@`, nor between the handle and the trailing one. */ internal class MentionedText( val text: AnnotatedString, /** Each handle's letters, `@` included, in [text]'s offsets. The pills are drawn from these. */ val handles: List, + /** + * One range per run of consecutive quoted lines, in [text]'s offsets. The bar is drawn down + * each, in the gutter [FormatStyles.quoteIndent] leaves. + */ + val quoteBars: List = emptyList(), +) + +/** How the parser's styles are drawn. Everything else the bubble owns: weight, colour, size. */ +internal class FormatStyles( + val code: SpanStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = Color.White.copy(alpha = 0.12f)), + val codeBlock: SpanStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = Color.White.copy(alpha = 0.12f)), + /** The gutter a quoted line is indented by, which holds the bar. */ + val quoteIndent: TextUnit = 12.sp, + /** Where the wrapped lines of a bulleted item start, past the `- `. */ + val bulletIndent: TextUnit = 12.sp, + /** The same for a numbered item, past `1. `. */ + val numberedIndent: TextUnit = 20.sp, ) internal const val MENTION_SPACING_SLOT = "mentionSpacing" -private const val MENTION_SPACING_STAND_IN = " " +private const val MENTION_SPACING_STAND_IN = " " internal fun buildMentionedText( text: String, @@ -66,19 +95,40 @@ internal fun buildMentionedText( linkStyle: SpanStyle, mentionStyle: SpanStyle, onMentionClick: ((String) -> Unit)?, + formatStyles: FormatStyles = FormatStyles(), ): MentionedText { - val ordered = mentions.sortedBy { it.start } + // Out of order or overlapping only if the caller handed over ranges from other text. + val ordered = mentions.sortedBy { it.start }.fold(emptyList()) { kept, mention -> + if (mention.start < (kept.lastOrNull()?.end ?: 0) || mention.end > text.length) kept else kept + mention + } + val parsed = parseChatText(text, detectUrls(text), ordered) + val display = parsed.display + + // The parser hands back every mention it was given, moved to display offsets and in order. + val mentionRanges = parsed.displayRanges.filter { it.kind == RangeKind.Mention } + val placed = if (mentionRanges.size == ordered.size) mentionRanges.zip(ordered) else emptyList() + + // Each placed mention adds a placeholder before and after its handle, so a display offset moves + // by two for every mention wholly before it and by one inside a handle. A boundary on a + // mention's own edge stays outside the leading placeholder and past the trailing one, which + // lets a style wrap a whole pill (`*@jeff*`). + fun laidOut(offset: Int): Int = offset + placed.sumOf { (range, _) -> + when { + range.end <= offset -> 2 + range.start < offset -> 1 + else -> 0 + }.toInt() + } + val handles = mutableListOf() val body = buildAnnotatedString { var cursor = 0 - ordered.forEach { mention -> - // Out of order or overlapping only if the caller handed over ranges from other text. - if (mention.start < cursor || mention.end > text.length) return@forEach - append(text, cursor, mention.start) + placed.forEach { (range, mention) -> + append(display, cursor, range.start) val tapStart = length appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN) val handleStart = length - append(text, mention.start, mention.end) + append(display, range.start, range.end) handles += TextRange(handleStart, length) appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN) // Over the spacing as well as the letters, so the whole pill takes the tap. @@ -94,24 +144,90 @@ internal fun buildMentionedText( end = length, ) } - cursor = mention.end + cursor = range.end } - append(text, cursor, text.length) + append(display, cursor, display.length) - // A link never overlaps a mention -- detection drops the mention -- so each link moves by - // the two placeholders of every mention before it. - detectUrls(text).forEach { link -> - val shift = 2 * ordered.count { it.start < link.start } + parsed.spans.forEach { span -> + val style = when (span.style) { + FormatStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold) + FormatStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic) + FormatStyle.Strike -> SpanStyle(textDecoration = TextDecoration.LineThrough) + FormatStyle.Code -> formatStyles.code + FormatStyle.CodeBlock -> formatStyles.codeBlock + // Whole lines, drawn below. + FormatStyle.Quote, FormatStyle.Bullet, FormatStyle.Numbered -> null + } ?: return@forEach + addStyle(style, laidOut(span.start), laidOut(span.end)) + } + + parsed.displayRanges.filter { it.kind == RangeKind.Link }.forEach { link -> + val url = link.target?.let { target -> detectUrls(target).singleOrNull()?.url ?: target } + ?: display.substring(link.start, link.end).let { shown -> + detectUrls(shown).firstOrNull()?.url ?: shown + } addLink( - LinkAnnotation.Url(url = link.url, styles = TextLinkStyles(style = linkStyle)), - start = link.start + shift, - end = link.end + shift, + LinkAnnotation.Url(url = url, styles = TextLinkStyles(style = linkStyle)), + start = laidOut(link.start), + end = laidOut(link.end), ) } } - return MentionedText(body, handles) + + val lines = display.lineRanges() + val quoted = lines.map { line -> parsed.spans.any { it.style == FormatStyle.Quote && it.covers(line) } } + val paragraphs = AnnotatedString.Builder(body) + lines.forEachIndexed { index, line -> + val listIndent = when { + parsed.spans.any { it.style == FormatStyle.Bullet && it.covers(line) } -> formatStyles.bulletIndent + parsed.spans.any { it.style == FormatStyle.Numbered && it.covers(line) } -> formatStyles.numberedIndent + else -> TextUnit.Unspecified + } + val quoteIndent = if (quoted[index]) formatStyles.quoteIndent else TextUnit.Unspecified + if (listIndent.isUnspecified && quoteIndent.isUnspecified) return@forEachIndexed + val lead = if (quoteIndent.isSpecified) quoteIndent else 0.sp + val rest = (lead.value + if (listIndent.isSpecified) listIndent.value else 0f).sp + // Through the newline, so the paragraphs tile the text and no stray one is left holding it. + val end = if (line.last < display.length) line.last + 1 else line.last + paragraphs.addStyle( + ParagraphStyle(textIndent = TextIndent(firstLine = lead, restLine = rest)), + laidOut(line.first), + laidOut(end), + ) + } + + val bars = mutableListOf() + var run: IntRange? = null + lines.forEachIndexed { index, line -> + if (quoted[index]) { + run = run?.let { it.first..line.last } ?: line + } else { + run?.let { bars += TextRange(laidOut(it.first), laidOut(it.last)) } + run = null + } + } + run?.let { bars += TextRange(laidOut(it.first), laidOut(it.last)) } + + return MentionedText(paragraphs.toAnnotatedString(), handles, bars) +} + +/** Each line's span, end exclusive of the newline; `last` is the offset of the line break. */ +private fun String.lineRanges(): List { + val out = mutableListOf() + var start = 0 + while (true) { + val newline = indexOf('\n', start) + if (newline < 0) { + out += start..length + return out + } + out += start..newline + start = newline + 1 + } } +private fun StyledSpan.covers(line: IntRange): Boolean = start < line.last && end > line.first + /** The placeholder either side of a handle, for the body's `inlineContent`. */ internal fun mentionSpacingContent(density: Density): Pair = MENTION_SPACING_SLOT to InlineTextContent( @@ -150,3 +266,17 @@ internal fun DrawScope.drawMentionPills(layout: TextLayoutResult, handles: List< } } } + +/** The bar down the leading edge of each run of quoted lines, from its first line's top to its last line's bottom. */ +internal fun DrawScope.drawQuoteBars(layout: TextLayoutResult, bars: List, color: Color) { + val width = QUOTE_BAR_WIDTH.toPx() + val radius = CornerRadius(width / 2) + bars.forEach { bar -> + if (bar.end > layout.layoutInput.text.length) return@forEach + val top = layout.getLineTop(layout.getLineForOffset(bar.start)) + val bottom = layout.getLineBottom(layout.getLineForOffset(maxOf(bar.start, bar.end - 1))) + drawRoundRect(color, Offset(0f, top), Size(width, bottom - top), radius) + } +} + +private val QUOTE_BAR_WIDTH = 3.dp diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 5a82541c2..9cfd79aa5 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -84,6 +84,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf import com.getcode.theme.CodeTheme import com.getcode.theme.cornerRadius +import com.getcode.theme.monospace import com.getcode.theme.tiny import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.core.addIf @@ -535,6 +536,15 @@ private fun TextBubble( color = CodeTheme.colors.textMain, textDecoration = TextDecoration.None, ) + val codeFamily = CodeTheme.typography.textMedium.monospace().fontFamily + val codeBackground = CodeTheme.colors.textMain.copy(alpha = 0.12f) + val formatStyles = remember(codeFamily, codeBackground) { + FormatStyles( + code = SpanStyle(fontFamily = codeFamily, background = codeBackground), + codeBlock = SpanStyle(fontFamily = codeFamily, background = codeBackground), + ) + } + val quoteBarColor = CodeTheme.colors.textSecondary // Read through the latest handler, so a recomposition that hands over a new lambda does // not rebuild the body; only whether mentions can be tapped at all is part of its key. val currentOnMentionClick by rememberUpdatedState(onMentionClick) @@ -543,7 +553,7 @@ private fun TextBubble( val mentioned = if (isTombstone) { null } else { - remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable) { + remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable, formatStyles) { buildMentionedText( text = bodyString, mentions = mentions, @@ -554,11 +564,13 @@ private fun TextBubble( } else { null }, + formatStyles = formatStyles, ) } } val body = mentioned?.text ?: AnnotatedString(bodyString) val handles = mentioned?.handles.orEmpty() + val quoteBars = mentioned?.quoteBars.orEmpty() val bodyStyle = CodeTheme.typography.textMedium.copy( fontWeight = FontWeight.Medium, fontStyle = if (isTombstone) FontStyle.Italic else FontStyle.Normal, @@ -625,10 +637,15 @@ private fun TextBubble( .padding(horizontal = bodyInset) .widestLine(measurer, laidOut, bodyStyle, inlineContent) .then( - if (handles.isEmpty()) { + if (handles.isEmpty() && quoteBars.isEmpty()) { Modifier } else { - Modifier.drawBehind { bodyLayout?.let { drawMentionPills(it, handles) } } + Modifier.drawBehind { + bodyLayout?.let { + drawMentionPills(it, handles) + drawQuoteBars(it, quoteBars, quoteBarColor) + } + } } ), text = laidOut, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 2ead5e583..16a18c2fc 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -1,5 +1,8 @@ package com.flipcash.shared.chat.ui +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import com.flipcash.core.R import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.BlobAccessContext @@ -30,6 +33,9 @@ import kotlin.time.Instant * typed, so the row has to look through it — otherwise a chat whose newest message is a reply * shows no preview at all. */ +// Previews run through the formatting parser, which detects links with android.util.Patterns. +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) class ChatSummaryPreviewTest { private val sentAt = Instant.fromEpochSeconds(1_000) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FormattedBubbleTextTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FormattedBubbleTextTest.kt new file mode 100644 index 000000000..9b28a387c --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FormattedBubbleTextTest.kt @@ -0,0 +1,156 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +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.unit.sp +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The bubble's body built from a parsed message: styles and links at the right offsets, mention + * placeholders accounted for. Robolectric for [detectUrls]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class FormattedBubbleTextTest { + + private val linkStyle = SpanStyle(textDecoration = TextDecoration.Underline) + private val mentionStyle = SpanStyle(textDecoration = TextDecoration.None) + + private fun build(text: String) = buildMentionedText( + text = text, + mentions = detectMentions(text, detectUrls(text)), + linkStyle = linkStyle, + mentionStyle = mentionStyle, + onMentionClick = {}, + ) + + private fun MentionedText.styled(predicate: (SpanStyle) -> Boolean): List = + text.spanStyles.filter { predicate(it.item) }.map { text.text.substring(it.start, it.end) } + + @Test + fun `markers are consumed and each style lands on its words`() { + val built = build("*bold* _italic_ ~gone~ `code`") + + assertEquals("bold italic gone code", built.text.text) + assertEquals(listOf("bold"), built.styled { it.fontWeight == FontWeight.Bold }) + assertEquals(listOf("italic"), built.styled { it.fontStyle == FontStyle.Italic }) + assertEquals(listOf("gone"), built.styled { it.textDecoration == TextDecoration.LineThrough }) + assertEquals(listOf("code"), built.styled { it.fontFamily != null }) + } + + @Test + fun `styles nest`() { + val built = build("*bold _and italic_*") + + assertEquals(listOf("bold and italic"), built.styled { it.fontWeight == FontWeight.Bold }) + assertEquals(listOf("and italic"), built.styled { it.fontStyle == FontStyle.Italic }) + } + + @Test + fun `a bold span that straddles a mention takes the mention's placeholders with it`() { + val built = build("*hi @jeff there*") + + // "hi " + placeholder + "@jeff" + placeholder + " there". + assertEquals(listOf(TextRange(4, 9)), built.handles) + val bold = built.text.spanStyles.single { it.item.fontWeight == FontWeight.Bold } + assertEquals(0 to built.text.length, bold.start to bold.end) + } + + @Test + fun `a style after a mention moves past both placeholders`() { + val built = build("@jeff is *here*") + + val bold = built.text.spanStyles.single { it.item.fontWeight == FontWeight.Bold } + assertEquals("here", built.text.text.substring(bold.start, bold.end)) + } + + @Test + fun `a style can wrap a whole mention`() { + val built = build("_@jeff_") + + assertEquals(1, built.handles.size) + assertEquals("@jeff", built.text.text.substring(built.handles.single().start, built.handles.single().end)) + val italic = built.text.spanStyles.single { it.item.fontStyle == FontStyle.Italic } + assertEquals(0 to built.text.length, italic.start to italic.end) + } + + @Test + fun `a masked link shows its label and opens its target`() { + val built = build("see [the docs](https://example.com/docs) now") + + assertEquals("see the docs now", built.text.text) + val link = built.text.getLinkAnnotations(0, built.text.length).single() + assertEquals("the docs", built.text.text.substring(link.start, link.end)) + assertEquals("https://example.com/docs", (link.item as LinkAnnotation.Url).url) + } + + @Test + fun `a bare link keeps opening its own resolved address`() { + val built = build("*example.com/a*") + + val link = built.text.getLinkAnnotations(0, built.text.length).single() + assertEquals("example.com/a", built.text.text.substring(link.start, link.end)) + assertEquals("https://example.com/a", (link.item as LinkAnnotation.Url).url) + } + + @Test + fun `list markers stay and wrapped lines are indented`() { + val built = build("- one\n1. two\nplain") + + assertEquals("- one\n1. two\nplain", built.text.text) + val indents = built.text.paragraphStyles.map { it.item.textIndent!!.restLine } + assertEquals(listOf(FormatStyles().bulletIndent, FormatStyles().numberedIndent), indents) + } + + @Test + fun `consecutive quoted lines get one bar`() { + val built = build("> one\n> two\nafter\n> again") + + assertEquals("one\ntwo\nafter\nagain", built.text.text) + assertEquals(listOf(TextRange(0, 7), TextRange(14, 19)), built.quoteBars) + } + + @Test + fun `a quoted list item is indented by both`() { + val styles = FormatStyles() + val built = build("> - a") + + val indent = built.text.paragraphStyles.single().item.textIndent!! + assertEquals(styles.quoteIndent, indent.firstLine) + assertEquals((styles.quoteIndent.value + styles.bulletIndent.value).sp, indent.restLine) + } + + @Test + fun `text with no markup is unchanged and carries no styling`() { + val built = build("just words, 2*3*4 and snake_case_name") + + assertEquals("just words, 2*3*4 and snake_case_name", built.text.text) + assertTrue(built.text.spanStyles.isEmpty()) + assertTrue(built.quoteBars.isEmpty()) + } + + @Test + fun `the display text drops markers and nothing else`() { + assertEquals("hi there", formattedDisplayText("*hi* _there_")) + assertEquals("one\n- two", formattedDisplayText("> one\n- two")) + } + + @Test + fun `a masked link never offers a card, a literal one does`() { + val cash = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + val masked = "[free money]($cash)" + assertEquals(emptyList(), cardLinks(masked)) + + val literal = "[free money] ($cash)" + assertEquals(listOf(cash), cardLinks(literal).map { it.url }) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt index fc577b511..d12aaa21b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt @@ -96,4 +96,35 @@ class MentionDetectionTest { assertEquals(emptyList(), handles("no handles here")) assertEquals(emptyList(), handles("just an @ sign")) } + + // Text-format spec, rule 6: one `_` may sit before the `@`, and then the handle's final `_` + // is the italic's closer rather than part of the name. + @Test + fun `an italic mention gives its closing underscore back`() { + val text = "_@jeff_" + val mention = detectMentions(text, emptyList()).single() + assertEquals("jeff", mention.username) + assertEquals(1 to 6, mention.start to mention.end) + } + + @Test + fun `a handle that really ends in an underscore is written with two`() { + assertEquals(listOf("jeff_"), handles("_@jeff__")) + } + + @Test + fun `nothing is trimmed when the handle does not end in an underscore`() { + assertEquals(listOf("jeff_x"), handles("_@jeff_x")) + } + + @Test + fun `an underscore that follows a handle character is not an opener`() { + assertEquals(emptyList(), handles("jeff_@gmail.com")) + assertEquals(emptyList(), handles("__@jeff__")) + } + + @Test + fun `an italic that ends in a mention keeps the underscore in the handle`() { + assertEquals(listOf("jeff_"), handles("_hey @jeff_")) + } } diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index fd45bb87c..0d679314d 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -38,5 +38,7 @@ dependencies { testImplementation(testFixtures(project(":services:flipcash"))) implementation(project(":libs:network:connectivity:public")) implementation(project(":libs:emojis")) + // api: chat-ui draws FormattedText, which this module's parse helper returns. + api(project(":libs:text-format")) implementation(libs.androidx.lifecycle.process) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 06223cac5..47c58c656 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -38,6 +38,7 @@ import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.internal.SenderResolver +import com.flipcash.shared.chat.ui.formattedDisplayText import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation @@ -328,7 +329,8 @@ class MessagingDelegate @Inject constructor( /** Text, photos and replies to either are what DMs encrypt; anything else keeps the server's body. */ private fun MessageContent.pushText(): String? = when (this) { - is MessageContent.Text -> text + // Built here from the decrypted text, so it can drop the markers the server never could. + is MessageContent.Text -> formattedDisplayText(text) is MessageContent.Media -> ChatMediaText.pushText(caption?.text) is MessageContent.Reply -> content.singleOrNull()?.pushText() else -> null diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt index be344bd18..c22d72e93 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt @@ -19,23 +19,35 @@ data class DetectedMention( * iOS's `MentionDetector` is the reference, and the pattern is the same one. * * The character before the `@` must not be a handle character, `.` or `@`, so an email address or - * `@@name` is not a mention. The handle ends at the first character a handle cannot hold, and a + * `@@name` is not a mention. A single `_` may stand between them, so `_@jeff_` is an italic + * mention; `jeff_@gmail.com` and `__@jeff__` are still not. The handle ends at the first character a handle cannot hold, and a * handle running straight into more handle characters or another `@` is dropped rather than cut * short. A handle inside a link (`x.com/@jeff`) is left to the link. */ fun detectMentions(text: String, links: List): List { if ('@' !in text) return emptyList() return MENTION_PATTERN.findAll(text).mapNotNull { match -> - val mention = match.groups[1] ?: return@mapNotNull null - val handle = match.groups[2] ?: return@mapNotNull null + val opener = match.groups[1]?.value.orEmpty() + val mention = match.groups[2] ?: return@mapNotNull null + val handle = match.groups[3] ?: return@mapNotNull null val start = mention.range.first - val end = mention.range.last + 1 + var end = mention.range.last + 1 + var username = handle.value + // `_@jeff_`: the final `_` closes the italic the leading one opened, as long as a handle + // of at least two characters is left. Without a leading `_` nothing is trimmed. + if (opener.isNotEmpty() && username.endsWith('_') && username.length - 1 >= MIN_HANDLE_LENGTH) { + username = username.dropLast(1) + end-- + } if (links.any { it.start < end && start < it.end }) return@mapNotNull null - DetectedMention(start = start, end = end, username = handle.value.lowercase()) + DetectedMention(start = start, end = end, username = username.lowercase()) }.toList() } +private const val MIN_HANDLE_LENGTH = 2 + // The preceding character is matched and left out of the mention's range, as iOS does for want of -// a lookbehind in Swift Regex, so the two platforms run one pattern. +// a lookbehind in Swift Regex, so the two platforms run one pattern. One `_` may sit between that +// character and the `@` (text-format spec, rule 6); it is not part of the mention. private val MENTION_PATTERN = - Regex("""(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])""") + Regex("""(?:^|[^A-Za-z0-9_.@])(_?)(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])""") diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt new file mode 100644 index 000000000..4a474f8fd --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt @@ -0,0 +1,49 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.libs.textformat.FormattedText +import com.flipcash.libs.textformat.ProtectedRange +import com.flipcash.libs.textformat.RangeKind +import com.flipcash.libs.textformat.parseTextFormat + +/** + * [text] run through the shared formatting parser, with the links and mentions this module detects + * as the ranges a marker may not open or close inside. + * + * Every surface that shows parsed text goes through here, so the transcript, the chat list and a + * notification cannot detect different ranges and format the same message differently. + */ +fun parseChatText( + text: String, + links: List = detectUrls(text), + mentions: List = detectMentions(text, links), +): FormattedText { + val ranges = buildList { + links.forEach { add(ProtectedRange(it.start, it.end, RangeKind.Link)) } + mentions.forEach { add(ProtectedRange(it.start, it.end, RangeKind.Mention)) } + } + return parseTextFormat(text, ranges) +} + +/** + * [text] as plain words: the markers the parser consumed are gone and no style is applied. This is + * what a reply quote, the chat list preview and a notification show; copy and edit keep the raw + * text instead. + */ +fun formattedDisplayText(text: String): String = parseChatText(text).display + +/** + * The links in [text] that may become a link card: every detected link except the URL of a masked + * `[label](url)`. + * + * A masked link shows `label` and opens somewhere else, so a card built from it would show a + * destination the reader never saw in the message (text-format spec, decision 4). A mask the + * parser leaves literal is not consumed, and its URL is an ordinary link. + */ +fun cardLinks(text: String, links: List = detectUrls(text)): List { + if (links.none { it.isMaskShaped(text) }) return links + val targets = parseChatText(text, links).displayRanges.mapNotNull { it.target }.toSet() + return links.filterNot { it.isMaskShaped(text) && text.substring(it.start, it.end) in targets } +} + +private fun DetectedUrl.isMaskShaped(text: String): Boolean = + start >= 2 && text[start - 1] == '(' && text[start - 2] == ']' && text.getOrNull(end) == ')' diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt index c1fe7fd24..3b0ab80c6 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -1,5 +1,8 @@ package com.flipcash.shared.chat +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.IncomingMessageOpener import com.flipcash.services.chat.ChatContentCrypto @@ -28,6 +31,9 @@ import kotlin.test.assertNull import kotlin.time.Instant /** What a DM or private group push shows: the opened plaintext, or `null` so the server's body stays. */ +// Previews run through the formatting parser, which detects links with android.util.Patterns. +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) class MessagingPushDecryptionTest { private val chatId = ChatId(ByteArray(32) { 7 })