feat: render NIP-84 highlights from web highlighter clients

Web-based highlighter clients publish kind:9802 highlights with W3C Web
Annotation selectors (textquoteselector / textpositionselector /
rangeselector) instead of a NIP-84 `context` tag. These were previously
ignored, so the highlight rendered without its surrounding paragraph and
the "jump to page" link couldn't disambiguate repeated quotes.

- Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/
  suffix; a "-" or empty exact is treated as a placeholder since the quote
  lives in .content).
- HighlightEvent.contextOrReconstructed() prefers an explicit `context`
  tag and otherwise rebuilds the paragraph from prefix + content + suffix,
  so the in-context bolding still works.
- Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`)
  from the selector's prefix/suffix so the source link scrolls to the
  correct occurrence.

The position/range selectors are left unparsed; they only matter for an
in-app live-page re-highlighter, which we don't have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh
This commit is contained in:
Claude
2026-07-17 21:40:21 +00:00
parent 56c99bb7b6
commit db586e30d3
4 changed files with 247 additions and 2 deletions
@@ -79,12 +79,16 @@ fun RenderHighlight(
) {
val noteEvent = note.event as? HighlightEvent ?: return
val selector = noteEvent.textQuoteSelector()
DisplayHighlight(
comment = noteEvent.comment(),
highlight = noteEvent.quote(),
context = noteEvent.context(),
context = noteEvent.contextOrReconstructed(),
authorHex = noteEvent.author(),
url = noteEvent.inUrl(),
textFragmentPrefix = selector?.prefix,
textFragmentSuffix = selector?.suffix,
postAddress = noteEvent.inPostAddress(),
postVersion = noteEvent.inPostVersion(),
makeItShort = makeItShort,
@@ -152,6 +156,8 @@ fun DisplayHighlight(
context: String?,
authorHex: String?,
url: String?,
textFragmentPrefix: String? = null,
textFragmentSuffix: String? = null,
postAddress: Address?,
postVersion: ETag?,
makeItShort: Boolean,
@@ -220,6 +226,8 @@ fun DisplayHighlight(
highlightQuote = highlight,
authorHex = authorHex,
baseUrl = url,
textFragmentPrefix = textFragmentPrefix,
textFragmentSuffix = textFragmentSuffix,
postAddress = postAddress,
postVersion = postVersion,
accountViewModel = accountViewModel,
@@ -228,11 +236,53 @@ fun DisplayHighlight(
}
}
private const val FRAGMENT_EDGE_WORDS = 4
/**
* Builds a URL with a Text Fragment (`#:~:text=`) directive that scrolls the source page
* to the highlighted text. When a W3C `textquoteselector` supplies surrounding prefix/suffix
* text, a few words of each are added as `prefix-,` / `,-suffix` disambiguators so the browser
* lands on the correct occurrence even when the quote repeats on the page.
*
* See https://wicg.github.io/scroll-to-text-fragment/
*/
private fun buildTextFragmentUrl(
baseUrl: String,
exact: String,
prefix: String?,
suffix: String?,
): String {
val separator = if (baseUrl.contains("#")) "&" else "#"
val prefixPart = trimToFragmentEdge(prefix, keepStart = false)?.let { "${Uri.encode(it)}-," } ?: ""
val suffixPart = trimToFragmentEdge(suffix, keepStart = true)?.let { ",-${Uri.encode(it)}" } ?: ""
return "$baseUrl$separator:~:text=$prefixPart${Uri.encode(exact)}$suffixPart"
}
/**
* Keeps only the [FRAGMENT_EDGE_WORDS] words nearest the highlight (the last words when
* [keepStart] is false, for a prefix; the first words when true, for a suffix) and collapses
* whitespace so newlines from the selector don't break Text Fragment matching.
*/
private fun trimToFragmentEdge(
text: String?,
keepStart: Boolean,
): String? {
if (text == null) return null
val words = text.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return null
val slice = if (keepStart) words.take(FRAGMENT_EDGE_WORDS) else words.takeLast(FRAGMENT_EDGE_WORDS)
return slice.joinToString(" ")
}
@Composable
private fun DisplayQuoteAuthor(
highlightQuote: String,
authorHex: String?,
baseUrl: String?,
textFragmentPrefix: String? = null,
textFragmentSuffix: String? = null,
postAddress: Address?,
postVersion: ETag?,
accountViewModel: AccountViewModel,
@@ -292,7 +342,10 @@ private fun DisplayQuoteAuthor(
}
baseUrl != null -> {
val url = "$baseUrl${if (baseUrl.contains("#")) "&" else "#"}:~:text=${Uri.encode(highlightQuote)}"
val url =
remember(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) {
buildTextFragmentUrl(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix)
}
DisplayEntryForAUrl(url, userBase, accountViewModel, nav)
}
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
import com.vitorpamplona.quartz.nip84Highlights.tags.TextQuoteSelectorTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -123,6 +124,23 @@ class HighlightEvent(
fun context() = tags.firstNotNullOfOrNull(ContextTag::parse)
fun textQuoteSelector() = tags.firstNotNullOfOrNull(TextQuoteSelectorTag::parse)
/**
* The paragraph-level context surrounding the highlight, preferring an explicit
* NIP-84 `context` tag and falling back to reconstructing it from a W3C
* `textquoteselector`'s prefix/suffix (as web highlighter clients emit) so the
* in-context rendering still works when no `context` tag is present.
*/
fun contextOrReconstructed(): String? {
context()?.let { return it }
val selector = textQuoteSelector() ?: return null
if (selector.prefix == null && selector.suffix == null) return null
return (selector.prefix ?: "") + content + (selector.suffix ?: "")
}
fun inPost() = firstTaggedATag()
fun inPostAddress() = firstTaggedAddress()
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* W3C Web Annotation TextQuoteSelector, as emitted by web-based highlighter clients
* alongside NIP-84 kind:9802 highlights. It anchors the highlight inside the source
* page ([HighlightEvent.inUrl]) by the exact text and the text immediately before
* (prefix) and after (suffix) it.
*
* The tag is laid out as `["textquoteselector", exact, prefix, suffix]`. The exact
* field is often a placeholder ("-" or empty) because the highlighted text is already
* carried by the event's `.content`; [exact] is null in that case.
*/
@Immutable
class TextQuoteSelectorTag(
val exact: String?,
val prefix: String?,
val suffix: String?,
) {
companion object {
const val TAG_NAME = "textquoteselector"
private fun field(value: String?): String? = value?.takeIf { it.isNotEmpty() && it != "-" }
fun parse(tag: Array<String>): TextQuoteSelectorTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val exact = field(tag.getOrNull(1))
val prefix = field(tag.getOrNull(2))
val suffix = field(tag.getOrNull(3))
ensure(exact != null || prefix != null || suffix != null) { return null }
return TextQuoteSelectorTag(exact, prefix, suffix)
}
fun assemble(
exact: String?,
prefix: String?,
suffix: String?,
) = arrayOf(TAG_NAME, exact ?: "-", prefix ?: "", suffix ?: "")
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip84Highlights
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class HighlightEventTest {
// The kind:9802 highlight emitted by a web highlighter client: no `context` tag,
// but W3C Web Annotation selectors carrying the surrounding prefix/suffix.
private val webHighlight =
HighlightEvent(
id = "8d7ae10a57ef178a17563a6ecbf9a399bb1796a2e032ca72703b00913b4cfd42",
pubKey = "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
createdAt = 1784322253,
tags =
arrayOf(
arrayOf("r", "https://geohot.github.io//blog/jekyll/update/2026/05/03/punk-or-why-i-dont-stream.html"),
arrayOf("textquoteselector", "-", "Your food is prechewed for you. ", "\n\nAnd its not like theres anyw"),
arrayOf("textpositionselector", "1861", "1938"),
arrayOf("rangeselector", "/main[1]/div[1]/article[1]/div[1]/p[5]", "/main[1]/div[1]/article[1]/div[1]/p[5]", "257", "334"),
),
content = "The caged tiger prefers a pot of meat slop to an antelope they have to chase.",
sig = "3d7040846e0e9fea7ebd58b3f3377290e6677f6b1fbef6bd6957cc76d262a7c190f4d17609a5cee941f9e24d6e131b78784a9e6e8d289efa8af813ad3c2f23ea",
)
@Test
fun parsesReferenceUrlAndIgnoresUnknownSelectorsForSource() {
assertEquals(
"https://geohot.github.io//blog/jekyll/update/2026/05/03/punk-or-why-i-dont-stream.html",
webHighlight.inUrl(),
)
assertNull(webHighlight.context())
assertNull(webHighlight.comment())
assertNull(webHighlight.author())
}
@Test
fun parsesTextQuoteSelectorTreatingDashExactAsPlaceholder() {
val selector = webHighlight.textQuoteSelector()
assertEquals(null, selector?.exact) // "-" placeholder means the quote is in .content
assertEquals("Your food is prechewed for you. ", selector?.prefix)
assertEquals("\n\nAnd its not like theres anyw", selector?.suffix)
}
@Test
fun reconstructsContextFromSelectorWhenNoContextTag() {
assertEquals(
"Your food is prechewed for you. The caged tiger prefers a pot of meat slop to an antelope they have to chase.\n\nAnd its not like theres anyw",
webHighlight.contextOrReconstructed(),
)
}
@Test
fun prefersExplicitContextTagOverSelectorReconstruction() {
val withContext =
HighlightEvent(
id = "00",
pubKey = "00",
createdAt = 0,
tags =
arrayOf(
arrayOf("context", "An explicit paragraph of context around the quote."),
arrayOf("textquoteselector", "-", "before ", " after"),
),
content = "the quote",
sig = "00",
)
assertEquals("An explicit paragraph of context around the quote.", withContext.contextOrReconstructed())
}
@Test
fun returnsNullContextWhenNeitherContextTagNorSelectorPresent() {
val bare =
HighlightEvent(
id = "00",
pubKey = "00",
createdAt = 0,
tags = arrayOf(arrayOf("r", "https://example.com")),
content = "a bare highlight",
sig = "00",
)
assertNull(bare.textQuoteSelector())
assertNull(bare.contextOrReconstructed())
}
}