mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+69
@@ -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,
|
||||
|
||||
+8
-9
@@ -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,
|
||||
|
||||
+7
-4
@@ -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,
|
||||
|
||||
+10
-4
@@ -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,
|
||||
|
||||
+8
-4
@@ -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(
|
||||
|
||||
+106
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user