diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index fa940f597b..ff8332b86c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -158,6 +158,8 @@ import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryT import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader +import com.vitorpamplona.amethyst.ui.actions.LocalCacheDao +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent @@ -247,6 +249,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver @@ -280,6 +283,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag @@ -2423,13 +2427,22 @@ class Account( // additionally carry the `h` tag and go only to the host relay. Attached media rides as // NIP-92 `imeta` tags, with each URL appended to the content so any client renders it. val rootEvent = rootNote.event ?: return false - val finalText = appendMediaUrls(text, imetas) + + // Resolve @/nostr: mentions the same way the full composer does, so a member cited in a + // quick reply is notified (`p`) and their reference resolves. The reply-parent author is + // already tagged by each builder below, so drop it from the body mentions to avoid a + // duplicate `p`. + val tagger = NewMessageTagger(text, emptyList(), emptyList(), LocalCacheDao) + tagger.run() + val mentions = tagger.pTags?.mapNotNull { it.pubkeyHex.takeIf { pk -> pk != rootEvent.pubKey } }.orEmpty() + val finalText = appendMediaUrls(tagger.message, imetas) gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat -> val relays = chat.relays() val signed = signer.sign( CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, relays.firstOrNull())) { + notify(mentions.map { PTag(it) }) imetas(imetas) }, ) @@ -2462,6 +2475,7 @@ class Account( hTag(group.groupId.id) buzzThread(root, rootEvent.id) rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } + pTags(mentions.map { PTag(it) }) previous(group.previousEventRefs(pubKey)) }, ) @@ -2470,6 +2484,7 @@ class Account( CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, hostRelay)) { hTag(group.groupId.id) previous(group.previousEventRefs(pubKey)) + notify(mentions.map { PTag(it) }) imetas(imetas) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index 5f3cf8eb5d..5988f7b790 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -30,11 +30,15 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.ui.actions.LocalCacheDao +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -199,6 +203,14 @@ class NotificationReplyReceiver : BroadcastReceiver() { val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return + // Resolve @/nostr: mentions typed into the notification reply, so a cited member is tagged + // (`p`) and linkable — the same enrichment the in-app composers do. The comment builders + // already tag the reply-parent author, so drop it from the body mentions to avoid a + // duplicate `p` (kind-1 doesn't auto-tag the parent, so nothing is lost there). + val tagger = NewMessageTagger(replyText, emptyList(), emptyList(), LocalCacheDao) + tagger.run() + val mentions = tagger.pTags?.mapNotNull { pt -> pt.pubkeyHex.takeIf { it != targetEvent.pubKey } }.orEmpty() + val template = when { // A brand-new Amethyst kind-1 thread root is replied to with a NIP-22 @@ -207,25 +219,31 @@ class NotificationReplyReceiver : BroadcastReceiver() { targetEvent.isNewThread() && targetEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) -> { CommentEvent.replyBuilder( - msg = replyText, + msg = tagger.message, replyingTo = EventHintBundle(targetEvent), - ) + ) { + notify(mentions.map { PTag(it) }) + } } targetEvent is TextNoteEvent -> { TextNoteEvent.build( - note = replyText, + note = tagger.message, replyingTo = EventHintBundle(targetEvent), - ) + ) { + notify(mentions.map { PTag(it) }) + } } else -> { // NIP-22 CommentEvent and other non-threaded events (e.g. long-form articles) // both reply via NIP-22 comments. CommentEvent.replyBuilder( - msg = replyText, + msg = tagger.message, replyingTo = EventHintBundle(targetEvent), - ) + ) { + notify(mentions.map { PTag(it) }) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/LocalCacheDao.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/LocalCacheDao.kt new file mode 100644 index 0000000000..a42a4e6f85 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/LocalCacheDao.kt @@ -0,0 +1,46 @@ +/* + * 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.ui.actions + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A [Dao] backed directly by [LocalCache], for resolving `@`/`nostr:` mentions with + * [NewMessageTagger] from paths that have no `AccountViewModel` to hand — the model-layer + * send helpers ([com.vitorpamplona.amethyst.model.Account]) and the background notification + * quick-reply receiver. Mirrors `AccountViewModel`'s own Dao delegation, which is just this. + * + * Resolving an npub/nprofile to its pubkey needs no populated cache (the key is in the + * bech32 itself), so this works even in a cold `:napplet`-free receiver process where + * LocalCache hasn't been rehydrated; only note-author lookups degrade there, which is fine. + */ +object LocalCacheDao : Dao { + override suspend fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex) + + override suspend fun getOrCreateNote(hex: HexKey): Note = LocalCache.getOrCreateNote(hex) + + override fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) +}