Merge pull request #3792 from vitorpamplona/claude/highlight-author-attribution-25e4nb

Fix highlight author detection and improve source display
This commit is contained in:
Vitor Pamplona
2026-07-28 22:21:02 -04:00
committed by GitHub
3 changed files with 81 additions and 4 deletions
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayEvent
import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
@@ -410,7 +411,11 @@ fun DisplayEntryForNote(
val noteEvent = noteState.note.event as? BaseThreadedEvent ?: return
val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject", "alt") }
// A real title/subject from an article or wiki page describes the source well. `alt`
// (NIP-31) is deliberately excluded: it's accessibility fallback text, not a caption, and
// clients such as Jumble fill it with a generic "This event was published by …" line that
// has nothing to do with the highlighted passage.
val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject") }
Text("-", maxLines = 1)
@@ -420,7 +425,13 @@ fun DisplayEntryForNote(
onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } },
)
} else {
DisplayEvent(noteEvent.id, note.toNostrUri(), null, accountViewModel, nav)
// No title to show — name the source by its event kind (e.g. "Note", "Blogs") rather
// than a raw @note1… id, and keep it clickable through to the source event.
val kindName = kindNameFor(LocalContext.current, noteEvent.kind)
ClickableTextPrimary(
text = kindName,
onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } },
)
}
}
@@ -116,7 +116,26 @@ class HighlightEvent(
fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse)
fun author() = firstTaggedUserId()
/**
* The pubkey of the author of the highlighted content.
*
* NIP-84 marks that person with an `"author"` role on their `p` tag
* (`["p", <pubkey>, <relay>, "author"]`) precisely so it can be told apart from the
* `"mention"` p tags a highlight may also carry. Prefer the marked tag; fall back to the
* first `p` tag for older/simpler highlights (including Amethyst's own) that tag only the
* author and omit the role marker.
*
* Without this the first `p` tag wins regardless of role, so a highlight that mentions
* other users before the author is attributed to a mention instead of the real author.
*/
fun author() =
tags.firstNotNullOfOrNull { tag ->
if (tag.size > 3 && tag[0] == PTag.TAG_NAME && tag[3] == AUTHOR_MARKER && tag[1].isNotEmpty()) {
tag[1]
} else {
null
}
} ?: firstTaggedUserId()
fun quote() = content
@@ -160,6 +179,9 @@ class HighlightEvent(
companion object {
const val KIND = 9802
/** NIP-84 role marker on the `p` tag that identifies the highlighted content's author. */
private const val AUTHOR_MARKER = "author"
/** Any run of whitespace (spaces, tabs, newlines) — collapsed to a single space. */
private val WHITESPACE_RUN = Regex("\\s+")
@@ -131,4 +131,48 @@ class HighlightEventTest {
assertNull(bare.textQuoteSelector())
assertNull(bare.contextOrReconstructed())
}
@Test
fun prefersTheAuthorMarkedPTagOverEarlierMentions() {
// A real kind:9802 highlighting a nostr note: three `mention` p tags precede the
// `author`-marked one. author() must return the author, not the first mention.
val highlight =
HighlightEvent(
id = "710dd9bcaa29618ad660db1a10fa0df12e684b161755369e14a459a98f80cc78",
pubKey = "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194",
createdAt = 1772184734,
tags =
arrayOf(
arrayOf("p", "0c45d7d45edb0fadda4215d36ca0d9aba0c771b85d3717764b8a128d5e443e4d", "", "mention"),
arrayOf("p", "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64", "", "mention"),
arrayOf("p", "4d7842051782e0d3feb034d150adc2b6bae4ee3b49786793bffa468b6f5b96b3", "", "mention"),
arrayOf("e", "54f1c0fbc3305dd98b3ce8e63ef04e9f4b149dc8de38b4275fae49beddb794eb", "wss://nos.lol/", "source"),
arrayOf("p", "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", "", "author"),
),
content = "Family and friendship and faith give men a sense of purpose.",
sig = "00",
)
assertEquals("dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", highlight.author())
}
@Test
fun fallsBackToTheFirstPTagWhenNoAuthorMarkerIsPresent() {
// Amethyst's own highlight publisher tags only the author, with no role marker.
val highlight =
HighlightEvent(
id = "00",
pubKey = "00",
createdAt = 0,
tags =
arrayOf(
arrayOf("a", "30023:eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac:bitcoin-here-now"),
arrayOf("p", "eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac"),
),
content = "a highlight of an article",
sig = "00",
)
assertEquals("eaa06714ac905aa5583860391e161edc7a815359b7c3e9b9b202c0558aefbeac", highlight.author())
}
}