Skip to content

Commit 303e8f6

Browse files
committed
feat(chat): render chat text formatting in bubbles, previews and pushes
Run message text through :libs:text-format with the detected links and mentions as protected ranges. The bubble styles bold, italic, strike, code, quotes and lists and shows a masked link as its label; reply quotes, chat list previews and push text show the display string with markers removed. Copy and edit keep the raw text. A masked link's URL no longer produces a link card or prefetch (spec decision 4). Mention detection accepts one underscore before the @ and gives the handle's closing underscore back (spec rule 6), so _@jeff_ is an italic mention. Previously it matched neither. ChatSummaryPreviewTest and MessagingPushDecryptionTest now run under Robolectric because previews call detectUrls.
1 parent ff44fde commit 303e8f6

13 files changed

Lines changed: 455 additions & 42 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ import com.flipcash.shared.chat.readOnly
143143
import com.flipcash.shared.chat.resolveCapabilities
144144
import com.flipcash.shared.chat.ui.UndecryptableHint
145145
import com.flipcash.shared.chat.ui.undecryptableHint
146+
import com.flipcash.shared.chat.ui.cardLinks
146147
import com.flipcash.shared.chat.ui.detectMentions
148+
import com.flipcash.shared.chat.ui.formattedDisplayText
147149
import com.flipcash.shared.chat.ui.detectUrls
148150
import com.flipcash.shared.chat.ui.linkableText
149151
import com.flipcash.shared.chat.withinWindows
@@ -1231,7 +1233,7 @@ internal class ChatViewModel @AssistedInject constructor(
12311233
// whether or not the reader ever scrolled to it.
12321234
val linkableText = enriched.linkableText()
12331235
val links = linkableText?.let(::detectUrls).orEmpty()
1234-
val linkCard = linkCardClassifier.firstCard(links)
1236+
val linkCard = linkableText?.let { linkCardClassifier.firstCard(cardLinks(it, links)) }
12351237

12361238
// Detected over the whole text, beside the links, so a handle inside a link
12371239
// stays the link's. Nothing is looked up until one is tapped.
@@ -1439,7 +1441,7 @@ internal class ChatViewModel @AssistedInject constructor(
14391441
},
14401442
)
14411443

1442-
is MessageContent.Text -> ChatQuoteSnippet.Text(body.text)
1444+
is MessageContent.Text -> ChatQuoteSnippet.Text(formattedDisplayText(body.text))
14431445

14441446
is MessageContent.Media -> body.toPhotoSnippet(this)
14451447

@@ -1457,7 +1459,7 @@ internal class ChatViewModel @AssistedInject constructor(
14571459
is MessageContent.Media -> inner.toPhotoSnippet(this)
14581460
else -> ChatQuoteSnippet.Text(
14591461
body.content.filterIsInstance<MessageContent.Text>()
1460-
.firstOrNull()?.text.orEmpty()
1462+
.firstOrNull()?.text?.let(::formattedDisplayText).orEmpty()
14611463
)
14621464
}
14631465

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import com.flipcash.services.models.chat.ChatId
55
import com.flipcash.services.models.chat.ChatMessage
66
import com.flipcash.shared.chat.MessageLinkPrefetch
77
import com.flipcash.shared.chat.models.LinkCard
8-
import com.flipcash.shared.chat.ui.detectUrls
8+
import com.flipcash.shared.chat.ui.cardLinks
99
import com.flipcash.shared.chat.ui.linkableText
1010
import kotlinx.coroutines.CoroutineScope
1111
import kotlinx.coroutines.Deferred
@@ -53,7 +53,7 @@ internal class MessageLinkPrefetcher(
5353
.asSequence()
5454
.filterNot { it.redacted }
5555
.flatMap { it.content.asSequence() }
56-
.mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(detectUrls(it)) } }
56+
.mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(cardLinks(it)) } }
5757
.mapNotNull { card -> lookup(card, webAllowed) }
5858
.toList()
5959

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ private fun MessageContent.previewText(
117117
depth: Int = 0,
118118
): String? = when (this) {
119119
is MessageContent.Text -> {
120-
val message = text.takeIf { it.isNotEmpty() }
120+
val message = formattedDisplayText(text).takeIf { it.isNotEmpty() }
121121
when {
122122
message == null -> null
123123
sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message)

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt‎

Lines changed: 155 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ import androidx.compose.ui.geometry.Size
88
import androidx.compose.ui.graphics.Color
99
import androidx.compose.ui.graphics.drawscope.DrawScope
1010
import androidx.compose.ui.text.AnnotatedString
11+
import androidx.compose.ui.text.ParagraphStyle
12+
import androidx.compose.ui.text.font.FontFamily
13+
import androidx.compose.ui.text.font.FontStyle
14+
import androidx.compose.ui.text.font.FontWeight
15+
import androidx.compose.ui.text.style.TextDecoration
16+
import androidx.compose.ui.text.style.TextIndent
17+
import androidx.compose.ui.unit.TextUnit
18+
import androidx.compose.ui.unit.isSpecified
19+
import androidx.compose.ui.unit.isUnspecified
20+
import com.flipcash.libs.textformat.FormatStyle
21+
import com.flipcash.libs.textformat.RangeKind
22+
import com.flipcash.libs.textformat.StyledSpan
1123
import androidx.compose.ui.text.LinkAnnotation
1224
import androidx.compose.ui.text.Placeholder
1325
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -40,45 +52,83 @@ internal object MentionPill {
4052
}
4153

4254
/**
43-
* A text bubble's body with its links and mentions in place, and where each handle's letters sit
44-
* in it.
55+
* A text bubble's body with its formatting, links and mentions in place, and where each handle's
56+
* letters sit in it.
4557
*
46-
* The body is not [text] verbatim: each mention is bracketed by a placeholder [MentionPill.spacing]
47-
* wide, which is what makes room for the pill without drawing over the words beside it. iOS kerns
48-
* the neighbouring characters for the same room; Compose has no per-character kern, and a
49-
* placeholder of known width is the nearest thing. Its stand-in character is a no-break space, so
50-
* the line can still break before the leading one but never between it and the `@`, nor between
51-
* the handle and the trailing one.
58+
* The body is not the message text verbatim. The parser's consumed markers are gone, and each
59+
* mention is bracketed by a placeholder [MentionPill.spacing] wide, which is what makes room for
60+
* the pill without drawing over the words beside it. iOS kerns the neighbouring characters for the
61+
* same room; Compose has no per-character kern, and a placeholder of known width is the nearest
62+
* thing. Its stand-in character is a no-break space, so the line can still break before the
63+
* leading one but never between it and the `@`, nor between the handle and the trailing one.
5264
*/
5365
internal class MentionedText(
5466
val text: AnnotatedString,
5567
/** Each handle's letters, `@` included, in [text]'s offsets. The pills are drawn from these. */
5668
val handles: List<TextRange>,
69+
/**
70+
* One range per run of consecutive quoted lines, in [text]'s offsets. The bar is drawn down
71+
* each, in the gutter [FormatStyles.quoteIndent] leaves.
72+
*/
73+
val quoteBars: List<TextRange> = emptyList(),
74+
)
75+
76+
/** How the parser's styles are drawn. Everything else the bubble owns: weight, colour, size. */
77+
internal class FormatStyles(
78+
val code: SpanStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = Color.White.copy(alpha = 0.12f)),
79+
val codeBlock: SpanStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = Color.White.copy(alpha = 0.12f)),
80+
/** The gutter a quoted line is indented by, which holds the bar. */
81+
val quoteIndent: TextUnit = 12.sp,
82+
/** Where the wrapped lines of a bulleted item start, past the `- `. */
83+
val bulletIndent: TextUnit = 12.sp,
84+
/** The same for a numbered item, past `1. `. */
85+
val numberedIndent: TextUnit = 20.sp,
5786
)
5887

5988
internal const val MENTION_SPACING_SLOT = "mentionSpacing"
6089

61-
private const val MENTION_SPACING_STAND_IN = " "
90+
private const val MENTION_SPACING_STAND_IN = " "
6291

6392
internal fun buildMentionedText(
6493
text: String,
6594
mentions: List<DetectedMention>,
6695
linkStyle: SpanStyle,
6796
mentionStyle: SpanStyle,
6897
onMentionClick: ((String) -> Unit)?,
98+
formatStyles: FormatStyles = FormatStyles(),
6999
): MentionedText {
70-
val ordered = mentions.sortedBy { it.start }
100+
// Out of order or overlapping only if the caller handed over ranges from other text.
101+
val ordered = mentions.sortedBy { it.start }.fold(emptyList<DetectedMention>()) { kept, mention ->
102+
if (mention.start < (kept.lastOrNull()?.end ?: 0) || mention.end > text.length) kept else kept + mention
103+
}
104+
val parsed = parseChatText(text, detectUrls(text), ordered)
105+
val display = parsed.display
106+
107+
// The parser hands back every mention it was given, moved to display offsets and in order.
108+
val mentionRanges = parsed.displayRanges.filter { it.kind == RangeKind.Mention }
109+
val placed = if (mentionRanges.size == ordered.size) mentionRanges.zip(ordered) else emptyList()
110+
111+
// Each placed mention adds a placeholder before and after its handle, so a display offset moves
112+
// by two for every mention wholly before it and by one inside a handle. A boundary on a
113+
// mention's own edge stays outside the leading placeholder and past the trailing one, which
114+
// lets a style wrap a whole pill (`*@jeff*`).
115+
fun laidOut(offset: Int): Int = offset + placed.sumOf { (range, _) ->
116+
when {
117+
range.end <= offset -> 2
118+
range.start < offset -> 1
119+
else -> 0
120+
}.toInt()
121+
}
122+
71123
val handles = mutableListOf<TextRange>()
72124
val body = buildAnnotatedString {
73125
var cursor = 0
74-
ordered.forEach { mention ->
75-
// Out of order or overlapping only if the caller handed over ranges from other text.
76-
if (mention.start < cursor || mention.end > text.length) return@forEach
77-
append(text, cursor, mention.start)
126+
placed.forEach { (range, mention) ->
127+
append(display, cursor, range.start)
78128
val tapStart = length
79129
appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN)
80130
val handleStart = length
81-
append(text, mention.start, mention.end)
131+
append(display, range.start, range.end)
82132
handles += TextRange(handleStart, length)
83133
appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN)
84134
// Over the spacing as well as the letters, so the whole pill takes the tap.
@@ -94,24 +144,90 @@ internal fun buildMentionedText(
94144
end = length,
95145
)
96146
}
97-
cursor = mention.end
147+
cursor = range.end
98148
}
99-
append(text, cursor, text.length)
149+
append(display, cursor, display.length)
100150

101-
// A link never overlaps a mention -- detection drops the mention -- so each link moves by
102-
// the two placeholders of every mention before it.
103-
detectUrls(text).forEach { link ->
104-
val shift = 2 * ordered.count { it.start < link.start }
151+
parsed.spans.forEach { span ->
152+
val style = when (span.style) {
153+
FormatStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
154+
FormatStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
155+
FormatStyle.Strike -> SpanStyle(textDecoration = TextDecoration.LineThrough)
156+
FormatStyle.Code -> formatStyles.code
157+
FormatStyle.CodeBlock -> formatStyles.codeBlock
158+
// Whole lines, drawn below.
159+
FormatStyle.Quote, FormatStyle.Bullet, FormatStyle.Numbered -> null
160+
} ?: return@forEach
161+
addStyle(style, laidOut(span.start), laidOut(span.end))
162+
}
163+
164+
parsed.displayRanges.filter { it.kind == RangeKind.Link }.forEach { link ->
165+
val url = link.target?.let { target -> detectUrls(target).singleOrNull()?.url ?: target }
166+
?: display.substring(link.start, link.end).let { shown ->
167+
detectUrls(shown).firstOrNull()?.url ?: shown
168+
}
105169
addLink(
106-
LinkAnnotation.Url(url = link.url, styles = TextLinkStyles(style = linkStyle)),
107-
start = link.start + shift,
108-
end = link.end + shift,
170+
LinkAnnotation.Url(url = url, styles = TextLinkStyles(style = linkStyle)),
171+
start = laidOut(link.start),
172+
end = laidOut(link.end),
109173
)
110174
}
111175
}
112-
return MentionedText(body, handles)
176+
177+
val lines = display.lineRanges()
178+
val quoted = lines.map { line -> parsed.spans.any { it.style == FormatStyle.Quote && it.covers(line) } }
179+
val paragraphs = AnnotatedString.Builder(body)
180+
lines.forEachIndexed { index, line ->
181+
val listIndent = when {
182+
parsed.spans.any { it.style == FormatStyle.Bullet && it.covers(line) } -> formatStyles.bulletIndent
183+
parsed.spans.any { it.style == FormatStyle.Numbered && it.covers(line) } -> formatStyles.numberedIndent
184+
else -> TextUnit.Unspecified
185+
}
186+
val quoteIndent = if (quoted[index]) formatStyles.quoteIndent else TextUnit.Unspecified
187+
if (listIndent.isUnspecified && quoteIndent.isUnspecified) return@forEachIndexed
188+
val lead = if (quoteIndent.isSpecified) quoteIndent else 0.sp
189+
val rest = (lead.value + if (listIndent.isSpecified) listIndent.value else 0f).sp
190+
// Through the newline, so the paragraphs tile the text and no stray one is left holding it.
191+
val end = if (line.last < display.length) line.last + 1 else line.last
192+
paragraphs.addStyle(
193+
ParagraphStyle(textIndent = TextIndent(firstLine = lead, restLine = rest)),
194+
laidOut(line.first),
195+
laidOut(end),
196+
)
197+
}
198+
199+
val bars = mutableListOf<TextRange>()
200+
var run: IntRange? = null
201+
lines.forEachIndexed { index, line ->
202+
if (quoted[index]) {
203+
run = run?.let { it.first..line.last } ?: line
204+
} else {
205+
run?.let { bars += TextRange(laidOut(it.first), laidOut(it.last)) }
206+
run = null
207+
}
208+
}
209+
run?.let { bars += TextRange(laidOut(it.first), laidOut(it.last)) }
210+
211+
return MentionedText(paragraphs.toAnnotatedString(), handles, bars)
212+
}
213+
214+
/** Each line's span, end exclusive of the newline; `last` is the offset of the line break. */
215+
private fun String.lineRanges(): List<IntRange> {
216+
val out = mutableListOf<IntRange>()
217+
var start = 0
218+
while (true) {
219+
val newline = indexOf('\n', start)
220+
if (newline < 0) {
221+
out += start..length
222+
return out
223+
}
224+
out += start..newline
225+
start = newline + 1
226+
}
113227
}
114228

229+
private fun StyledSpan.covers(line: IntRange): Boolean = start < line.last && end > line.first
230+
115231
/** The placeholder either side of a handle, for the body's `inlineContent`. */
116232
internal fun mentionSpacingContent(density: Density): Pair<String, InlineTextContent> =
117233
MENTION_SPACING_SLOT to InlineTextContent(
@@ -150,3 +266,17 @@ internal fun DrawScope.drawMentionPills(layout: TextLayoutResult, handles: List<
150266
}
151267
}
152268
}
269+
270+
/** The bar down the leading edge of each run of quoted lines, from its first line's top to its last line's bottom. */
271+
internal fun DrawScope.drawQuoteBars(layout: TextLayoutResult, bars: List<TextRange>, color: Color) {
272+
val width = QUOTE_BAR_WIDTH.toPx()
273+
val radius = CornerRadius(width / 2)
274+
bars.forEach { bar ->
275+
if (bar.end > layout.layoutInput.text.length) return@forEach
276+
val top = layout.getLineTop(layout.getLineForOffset(bar.start))
277+
val bottom = layout.getLineBottom(layout.getLineForOffset(maxOf(bar.start, bar.end - 1)))
278+
drawRoundRect(color, Offset(0f, top), Size(width, bottom - top), radius)
279+
}
280+
}
281+
282+
private val QUOTE_BAR_WIDTH = 3.dp

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import com.getcode.opencode.model.financial.Token
8484
import com.getcode.opencode.model.financial.usdf
8585
import com.getcode.theme.CodeTheme
8686
import com.getcode.theme.cornerRadius
87+
import com.getcode.theme.monospace
8788
import com.getcode.theme.tiny
8889
import com.getcode.ui.components.PriceWithFlag
8990
import com.getcode.ui.core.addIf
@@ -535,6 +536,15 @@ private fun TextBubble(
535536
color = CodeTheme.colors.textMain,
536537
textDecoration = TextDecoration.None,
537538
)
539+
val codeFamily = CodeTheme.typography.textMedium.monospace().fontFamily
540+
val codeBackground = CodeTheme.colors.textMain.copy(alpha = 0.12f)
541+
val formatStyles = remember(codeFamily, codeBackground) {
542+
FormatStyles(
543+
code = SpanStyle(fontFamily = codeFamily, background = codeBackground),
544+
codeBlock = SpanStyle(fontFamily = codeFamily, background = codeBackground),
545+
)
546+
}
547+
val quoteBarColor = CodeTheme.colors.textSecondary
538548
// Read through the latest handler, so a recomposition that hands over a new lambda does
539549
// not rebuild the body; only whether mentions can be tapped at all is part of its key.
540550
val currentOnMentionClick by rememberUpdatedState(onMentionClick)
@@ -543,7 +553,7 @@ private fun TextBubble(
543553
val mentioned = if (isTombstone) {
544554
null
545555
} else {
546-
remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable) {
556+
remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable, formatStyles) {
547557
buildMentionedText(
548558
text = bodyString,
549559
mentions = mentions,
@@ -554,11 +564,13 @@ private fun TextBubble(
554564
} else {
555565
null
556566
},
567+
formatStyles = formatStyles,
557568
)
558569
}
559570
}
560571
val body = mentioned?.text ?: AnnotatedString(bodyString)
561572
val handles = mentioned?.handles.orEmpty()
573+
val quoteBars = mentioned?.quoteBars.orEmpty()
562574
val bodyStyle = CodeTheme.typography.textMedium.copy(
563575
fontWeight = FontWeight.Medium,
564576
fontStyle = if (isTombstone) FontStyle.Italic else FontStyle.Normal,
@@ -625,10 +637,15 @@ private fun TextBubble(
625637
.padding(horizontal = bodyInset)
626638
.widestLine(measurer, laidOut, bodyStyle, inlineContent)
627639
.then(
628-
if (handles.isEmpty()) {
640+
if (handles.isEmpty() && quoteBars.isEmpty()) {
629641
Modifier
630642
} else {
631-
Modifier.drawBehind { bodyLayout?.let { drawMentionPills(it, handles) } }
643+
Modifier.drawBehind {
644+
bodyLayout?.let {
645+
drawMentionPills(it, handles)
646+
drawQuoteBars(it, quoteBars, quoteBarColor)
647+
}
648+
}
632649
}
633650
),
634651
text = laidOut,

‎apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.flipcash.shared.chat.ui
22

3+
import org.junit.runner.RunWith
4+
import org.robolectric.RobolectricTestRunner
5+
import org.robolectric.annotation.Config
36
import com.flipcash.core.R
47
import com.flipcash.services.models.UserProfile
58
import com.flipcash.services.models.chat.BlobAccessContext
@@ -30,6 +33,9 @@ import kotlin.time.Instant
3033
* typed, so the row has to look through it — otherwise a chat whose newest message is a reply
3134
* shows no preview at all.
3235
*/
36+
// Previews run through the formatting parser, which detects links with android.util.Patterns.
37+
@RunWith(RobolectricTestRunner::class)
38+
@Config(manifest = Config.NONE)
3339
class ChatSummaryPreviewTest {
3440

3541
private val sentAt = Instant.fromEpochSeconds(1_000)

0 commit comments

Comments
 (0)