diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index dedef008de..795199f211 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index f1fdbe1e97..60ac3be542 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/tags/TextQuoteSelectorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/tags/TextQuoteSelectorTag.kt new file mode 100644 index 0000000000..58c6a0a127 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/tags/TextQuoteSelectorTag.kt @@ -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): 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 ?: "") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt new file mode 100644 index 0000000000..146ce5bb89 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt @@ -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 it’s not like there’s 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 it’s not like there’s 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 it’s not like there’s 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()) + } +}