fix: bound the context shown around a highlight to a window

A kind:9802 highlight can carry a huge `context` tag — a quote pulled from
the middle of a long article may ship several paragraphs of surrounding
text. Rendered whole, that fills the feed card with paragraphs around a
one-sentence highlight.

Trim the context in `HighlightQuote.of` to at most ~160 characters on each
side of the marked quote, snapping the cut to a whole-word boundary and
marking it with an ellipsis. The quote itself is always kept in full and
the marked range is re-based onto the trimmed text, so the in-context
marker still lands exactly on the highlighted passage. Short contexts are
left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
This commit is contained in:
Claude
2026-07-28 21:03:56 +00:00
parent 4d24e2b09c
commit 89aea073ed
2 changed files with 110 additions and 4 deletions
@@ -24,9 +24,11 @@ package com.vitorpamplona.amethyst.commons.model.highlights
* What to render for a NIP-84 highlight: the passage to show, and the range inside it that
* the user actually marked.
*
* When the event carries a `context` tag the whole surrounding sentence is shown with the
* quote marked inside it. When it doesn't — or the quote can't be located in the context —
* [text] is the quote alone and [marked] is null, meaning "mark all of it".
* When the event carries a `context` tag the surrounding passage is shown with the quote
* marked inside it, trimmed to a bounded window on each side so a quote pulled from the
* middle of a long article doesn't drag whole paragraphs into the feed. When it doesn't —
* or the quote can't be located in the context — [text] is the quote alone and [marked] is
* null, meaning "mark all of it".
*/
data class HighlightQuote(
val text: String,
@@ -49,7 +51,7 @@ data class HighlightQuote(
val at = locate(context, highlight, prefix)
return if (at != null) {
HighlightQuote(context, at until (at + highlight.length))
window(context, at, at + highlight.length)
} else {
// Context that doesn't actually contain the quote is worse than no context:
// it would mark nothing and silently show text the user never highlighted.
@@ -57,6 +59,75 @@ data class HighlightQuote(
}
}
/**
* Most surrounding context to keep on each side of the marked quote, in characters.
* A highlight taken from the middle of a long article can carry the whole article in
* its `context` tag; without a cap the feed card would render several paragraphs around
* a one-sentence highlight. Just enough to frame the quote, no more.
*/
private const val MAX_CONTEXT_CHARS_PER_SIDE = 160
private const val ELLIPSIS = ""
/**
* Trims the context down to [MAX_CONTEXT_CHARS_PER_SIDE] on each side of the quote,
* snapping the cut to a whole-word boundary and marking it with an ellipsis. The quote
* itself ([start] until [endExclusive]) is always kept in full, and the returned
* [marked] range is re-based onto the trimmed text.
*/
private fun window(
context: String,
start: Int,
endExclusive: Int,
): HighlightQuote {
val lead = trimLead(context.substring(0, start))
val trail = trimTrail(context.substring(endExclusive))
val quote = context.substring(start, endExclusive)
// Nothing to trim on either side: the original context is short enough to show whole.
if (!lead.trimmed && !trail.trimmed) {
return HighlightQuote(context, start until endExclusive)
}
val prefix = if (lead.trimmed) "$ELLIPSIS " else ""
val suffix = if (trail.trimmed) " $ELLIPSIS" else ""
val text = prefix + lead.text + quote + trail.text + suffix
val markStart = prefix.length + lead.text.length
return HighlightQuote(text, markStart until (markStart + quote.length))
}
private class Side(
val text: String,
val trimmed: Boolean,
)
/** Keeps the tail of the leading context, starting at a whole word. */
private fun trimLead(text: String): Side {
if (text.length <= MAX_CONTEXT_CHARS_PER_SIDE) return Side(text, false)
var i = text.length - MAX_CONTEXT_CHARS_PER_SIDE
// Skip the partial word the budget landed inside, then the whitespace after it, so
// the kept text begins at the start of a whole word rather than mid-word.
while (i < text.length && !text[i].isWhitespace()) i++
while (i < text.length && text[i].isWhitespace()) i++
val cut = if (i >= text.length) text.length - MAX_CONTEXT_CHARS_PER_SIDE else i
return Side(text.substring(cut), true)
}
/** Keeps the head of the trailing context, ending at a whole word. */
private fun trimTrail(text: String): Side {
if (text.length <= MAX_CONTEXT_CHARS_PER_SIDE) return Side(text, false)
var i = MAX_CONTEXT_CHARS_PER_SIDE
// Retreat over the partial word the budget landed inside, then the whitespace before
// it, so the kept text ends at the end of a whole word rather than mid-word.
while (i > 0 && !text[i - 1].isWhitespace()) i--
while (i > 0 && text[i - 1].isWhitespace()) i--
val cut = if (i <= 0) MAX_CONTEXT_CHARS_PER_SIDE else i
return Side(text.substring(0, cut), true)
}
/**
* Finds [highlight] inside [context], preferring the occurrence whose preceding text
* ends with [prefix]. Highlighters emit that prefix precisely so a repeated quote can
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.highlights
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HighlightQuoteTest {
@Test
@@ -89,4 +90,38 @@ class HighlightQuoteTest {
assertEquals("some context", quote.text)
assertNull(quote.marked)
}
@Test
fun keepsShortContextWholeWithoutEllipsis() {
val quote = HighlightQuote.of("the merge happens slowly", "We think the merge happens slowly. It does.")
assertEquals("We think the merge happens slowly. It does.", quote.text)
assertTrue('…' !in quote.text)
}
@Test
fun trimsLongContextToAWindowAroundTheQuote() {
val filler = "word ".repeat(200).trim() // ~1000 chars of context on each side
val context = "$filler the marked quote $filler"
val quote = HighlightQuote.of("the marked quote", context)
// The whole quote survives and stays marked...
assertEquals("the marked quote", quote.text.substring(quote.marked!!))
// ...but the surrounding text is trimmed with an ellipsis on each side...
assertTrue(quote.text.startsWith(""))
assertTrue(quote.text.endsWith(""))
// ...and the result is a small fraction of the original two-paragraph context.
assertTrue(quote.text.length < context.length / 2, "expected windowing, got ${quote.text.length} of ${context.length}")
}
@Test
fun trimmingSnapsToWholeWordsSoNoWordIsCutInHalf() {
val lead = "alpha bravo charlie delta echo foxtrot ".repeat(20) // long, space-separated
val context = "${lead}QUOTE"
val quote = HighlightQuote.of("QUOTE", context)
// The kept lead-in starts right after the ellipsis with a whole word, never a fragment.
val keptLead = quote.text.removePrefix("").removeSuffix("QUOTE")
assertTrue(keptLead.split(" ").first() in setOf("alpha", "bravo", "charlie", "delta", "echo", "foxtrot"))
}
}