feat: resolve inline @npub mentions to display names in notifications

Notification bodies previously showed raw `nostr:npub1…` / `nostr:nprofile1…`
tokens for anyone cited in the text — the name never filled in even after the
cited user's kind:0 loaded, because the excerpt was a static substring taken
once and never re-resolved against the metadata cache.

Add NotificationContent.resolveMentions(), which rewrites each cited npub/
nprofile token to `@<best display name>` and returns the cited Users so the
renderer can add them to its enrichment window. Event references (nevent/note/
naddr) are left verbatim — they have no name to show. Mention, Reply, Media and
Article renderers now recompute the body inside the observable build closure and
observe the cited users, so the text flips from `@npub1abc…` to `@RealName` in
place, matching the author-name enrichment the title already had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
This commit is contained in:
Claude
2026-07-24 02:53:39 +00:00
parent c5941942be
commit c5b8062a0c
6 changed files with 208 additions and 21 deletions
@@ -20,10 +20,15 @@
*/
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -45,6 +50,70 @@ object NotificationContent {
?.take(max)
?: ""
/**
* The result of [resolveMentions]: the excerpt with every `nostr:npub` /
* `nostr:nprofile` token swapped for the cited user's `@DisplayName`, plus the
* [User]s that were cited so a renderer can add them to its enrichment window
* and re-render as their metadata loads.
*/
data class ResolvedText(
val text: String,
val citedUsers: List<User>,
)
/**
* Like [excerpt], but rewrites inline user mentions to readable names: each
* `nostr:npub1…` / `nostr:nprofile1…` (optionally `@`-prefixed) becomes
* `@<best display name>` for the cited user, and that user is returned in
* [ResolvedText.citedUsers]. Event/address references (`nevent`, `note`,
* `naddr`, …) are left untouched — they aren't people and have no name to show.
*
* Called from the build closure on every re-render, so as a cited user's
* kind:0 arrives the notification text updates in place from `@npub1abc…` to
* `@RealName`.
*/
fun resolveMentions(
content: String?,
max: Int = 280,
): ResolvedText {
val line =
content
?.split("\n")
?.firstOrNull { it.isNotBlank() }
?: return ResolvedText("", emptyList())
// Cheap opt-out: no mention tokens means no work and no allocations.
if (!line.contains("npub1", ignoreCase = true) && !line.contains("nprofile1", ignoreCase = true)) {
return ResolvedText(line.take(max), emptyList())
}
val cited = mutableListOf<User>()
val rewritten =
Nip19Parser.nip19regex.replace(line) { match ->
val type = match.groups[3]?.value ?: match.groups[5]?.value
val key = match.groups[4]?.value ?: match.groups[6]?.value
val trailing = match.groups[7]?.value ?: ""
val hex =
when (val entity = Nip19Parser.parseComponents(type ?: "", key, null)?.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
if (hex != null) {
val user = LocalCache.getOrCreateUser(hex)
cited.add(user)
"@${user.toBestDisplayName()}$trailing"
} else {
// nevent / note / naddr / parse failure — leave as-is.
match.value
}
}
return ResolvedText(rewritten.take(max), cited)
}
suspend fun decryptZapContentAuthor(
event: LnZapRequestEvent,
signer: NostrSigner,
@@ -60,12 +60,8 @@ object ArticleNotification {
} else {
R.string.app_notification_articles_channel_message
}
val body =
if (event is HighlightEvent) {
NotificationContent.excerpt(event.quote())
} else {
NotificationContent.excerpt(event.content)
}
val bodySource = if (event is HighlightEvent) event.quote() else event.content
val citedUsers = NotificationContent.resolveMentions(bodySource).citedUsers
val nm = context.notificationManager()
@@ -73,15 +69,18 @@ object ArticleNotification {
context = context,
account = account,
notificationId = event.id,
users = listOf(author),
users = listOf(author) + citedUsers,
notes = listOf(note),
isComplete = { author.metadataOrNull()?.bestName() != null },
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
nm.postStandard(
category = NotificationCategory.ARTICLE,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = body,
messageBody = NotificationContent.resolveMentions(bodySource).text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
@@ -54,7 +54,7 @@ object MediaNotification {
val uri = NotificationRoutes.noteUri(note, accountNpub)
val isVideo = event is VideoEvent
val bigPictureUrl = NotificationContent.mediaImageUrl(event)
val caption = NotificationContent.excerpt(event.content, 140)
val citedUsers = NotificationContent.resolveMentions(event.content, 140).citedUsers
val nm = context.notificationManager()
@@ -62,9 +62,12 @@ object MediaNotification {
context = context,
account = account,
notificationId = event.id,
users = listOf(author),
users = listOf(author) + citedUsers,
notes = listOf(note),
isComplete = { author.metadataOrNull()?.bestName() != null },
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val user = author.toBestDisplayName()
val titleRes =
@@ -77,7 +80,7 @@ object MediaNotification {
category = NotificationCategory.MEDIA,
id = event.id,
messageTitle = stringRes(context, titleRes, user),
messageBody = caption,
messageBody = NotificationContent.resolveMentions(event.content, 140).text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
@@ -57,7 +57,10 @@ object MentionNotification {
val author = LocalCache.getOrCreateUser(event.pubKey)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(note, accountNpub)
val body = NotificationContent.excerpt(event.content)
// Users cited inline in the text (nostr:npub/nprofile) are observed too, so
// their names fill in as their kind:0 metadata arrives.
val citedUsers = NotificationContent.resolveMentions(event.content).citedUsers
val nm = context.notificationManager()
@@ -65,15 +68,18 @@ object MentionNotification {
context = context,
account = account,
notificationId = event.id,
users = listOf(author),
users = listOf(author) + citedUsers,
notes = listOf(note),
isComplete = { author.metadataOrNull()?.bestName() != null },
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
nm.postStandard(
category = category,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = body,
messageBody = NotificationContent.resolveMentions(event.content).text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
@@ -61,8 +61,7 @@ object ReplyNotification {
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(replyNote, accountNpub)
val replyExcerpt = NotificationContent.excerpt(event.content)
val parentExcerpt = parentContent?.let { NotificationContent.excerpt(it, 140) }?.takeIf { it.isNotBlank() }
val citedUsers = NotificationContent.resolveMentions(event.content).citedUsers
val nm = context.notificationManager()
@@ -70,11 +69,16 @@ object ReplyNotification {
context = context,
account = account,
notificationId = event.id,
users = listOf(author),
users = listOf(author) + citedUsers,
notes = listOf(replyNote),
isComplete = { author.metadataOrNull()?.bestName() != null },
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val user = author.toBestDisplayName()
val replyExcerpt = NotificationContent.resolveMentions(event.content).text
val parentExcerpt = parentContent?.let { NotificationContent.resolveMentions(it, 140).text }?.takeIf { it.isNotBlank() }
val parent =
parentExcerpt?.let {
ParentMessage(
@@ -0,0 +1,106 @@
/*
* 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.amethyst.service.notifications
import android.os.Looper
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* [NotificationContent.resolveMentions] rewrites inline `nostr:npub` / `nostr:nprofile` tokens to a
* cited user's `@DisplayName` and returns those users so a renderer can observe them until their kind:0
* loads. This is what makes a notification body flip from `@npub1abc` to `@RealName` in place, matching
* the author-name enrichment the shade already does for the title.
*/
class NotificationContentMentionTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!!
private val cited = NostrSignerInternal(KeyPair())
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper (null == null reads
// as "main"). Distinct mocks make it a worker thread. See BuzzDmNotificationResolutionTest.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
}
@Test
fun `an unknown cited npub is replaced with its short handle and returned for observation`() {
val npub = NPub.create(cited.pubKey)
val resolved = NotificationContent.resolveMentions("hey nostr:$npub how are you")
assertFalse("the raw npub token is gone", resolved.text.contains(npub))
assertTrue("it is replaced by an @handle", resolved.text.contains("@"))
assertEquals("the cited user is returned for the enrichment window", 1, resolved.citedUsers.size)
assertEquals(cited.pubKey, resolved.citedUsers.first().pubkeyHex)
}
@Test
fun `a cited npub whose kind0 is loaded renders the real display name`() =
runBlocking {
val metadata = cited.sign(MetadataEvent.createNew(name = "Alice"))
LocalCache.checkDeletionAndConsume(metadata, relay, false)
val npub = NPub.create(cited.pubKey)
val resolved = NotificationContent.resolveMentions("gm nostr:$npub")
assertTrue("the display name fills in", resolved.text.contains("@Alice"))
assertEquals(1, resolved.citedUsers.size)
}
@Test
fun `event references are left untouched and not treated as cited users`() {
val nevent = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtnwda"
val resolved = NotificationContent.resolveMentions("look at nostr:$nevent here")
assertTrue("the event ref stays verbatim", resolved.text.contains(nevent))
assertTrue("no user was cited", resolved.citedUsers.isEmpty())
}
@Test
fun `plain text with no mentions passes through unchanged`() {
val resolved = NotificationContent.resolveMentions("just a normal note with no tags")
assertEquals("just a normal note with no tags", resolved.text)
assertTrue(resolved.citedUsers.isEmpty())
}
}