Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -1457,7 +1459,7 @@ internal class ChatViewModel @AssistedInject constructor(
is MessageContent.Media -> inner.toPhotoSnippet(this)
else -> ChatQuoteSnippet.Text(
body.content.filterIsInstance<MessageContent.Text>()
.firstOrNull()?.text.orEmpty()
.firstOrNull()?.text?.let(::formattedDisplayText).orEmpty()
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,45 +52,83 @@ 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<TextRange>,
/**
* 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<TextRange> = 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,
mentions: List<DetectedMention>,
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<DetectedMention>()) { 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<TextRange>()
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.
Expand All @@ -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<TextRange>()
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<IntRange> {
val out = mutableListOf<IntRange>()
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<String, InlineTextContent> =
MENTION_SPACING_SLOT to InlineTextContent(
Expand Down Expand Up @@ -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<TextRange>, 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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading