diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt index 64505f5a22..fc1d5b103f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt @@ -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, + ) + + /** + * Like [excerpt], but rewrites inline user mentions to readable names: each + * `nostr:npub1…` / `nostr:nprofile1…` (optionally `@`-prefixed) becomes + * `@` 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() + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt index d1a05b2a9d..c50344a394 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt index 7f4c47928d..c073fb605d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt index 08f173e868..e1a0380350 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt index 1e8eeee6c9..01bfa0d955 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt @@ -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( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt new file mode 100644 index 0000000000..1fd00192d2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt @@ -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() + every { Looper.getMainLooper() } returns mockk() + } + + @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()) + } +}