feat(chat): resolve mentions in minichat and notification quick-replies

The lightweight reply paths built their events from raw text, so a person
cited with `@name`/`nostr:` was neither resolved into a `nostr:` reference
nor tagged with `p`. Run NewMessageTagger on these paths too, backed by a
new LocalCache-based Dao so they work without an AccountViewModel:

- New `LocalCacheDao`: a `Dao` delegating to `LocalCache`, for mention
  resolution outside a ViewModel (model-layer sends, background receivers).
- `Account.sendMinichatReply` (kinds 9 + 1111): resolve mentions and emit
  `p` tags for cited users across the public-chat, Buzz, and NIP-29 group
  branches (parent author excluded to avoid a duplicate).
- `NotificationReplyReceiver.sendPublicReply` (kinds 1 + 1111): same
  resolution for the system notification quick-reply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
This commit is contained in:
Claude
2026-07-27 19:46:00 +00:00
parent 11147aac10
commit d305c03e54
3 changed files with 86 additions and 7 deletions
@@ -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)
},
)
@@ -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) })
}
}
}
@@ -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)
}