feat: reply with kind 1111 to new Amethyst kind-1 thread roots

When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.

Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).

Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
This commit is contained in:
Claude
2026-06-20 15:00:04 +00:00
parent 1223f5b832
commit 96c5d9bcb6
6 changed files with 167 additions and 17 deletions
@@ -29,11 +29,13 @@ import androidx.core.content.ContextCompat
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.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -198,8 +200,19 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return
val template =
when (targetEvent) {
is TextNoteEvent -> {
when {
// A brand-new Amethyst kind-1 thread root is replied to with a NIP-22
// kind 1111 Comment instead of a kind 1 reply.
targetEvent is TextNoteEvent &&
targetEvent.isNewThread() &&
targetEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) -> {
CommentEvent.replyBuilder(
msg = replyText,
replyingTo = EventHintBundle(targetEvent),
)
}
targetEvent is TextNoteEvent -> {
TextNoteEvent.build(
note = replyText,
replyingTo = EventHintBundle(targetEvent),
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.ai.MockWritingAssistant
import com.vitorpamplona.amethyst.service.ai.WritingAssistant
import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory
@@ -113,6 +114,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
@@ -132,6 +134,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.alt
@@ -1087,6 +1090,38 @@ open class ShortNotePostViewModel :
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
} else if (shouldReplyAsComment()) {
// NIP-22: replies to a brand-new Amethyst kind-1 thread root are sent as
// kind 1111 Comments instead of kind 1 replies.
val eventHint = originalNote?.toEventHint<Event>() ?: return null
CommentEvent.replyBuilder(tagger.message, eventHint) {
tagger.pTags?.let { userList ->
val tags =
userList.map {
val tag = it.toPTag()
if (tag.relayHint == null) {
tag.copy(relayHint = LocalCache.relayHints.hintsForKey(it.pubkeyHex).firstOrNull())
} else {
tag
}
}
notify(tags)
}
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
@@ -1149,6 +1184,19 @@ open class ShortNotePostViewModel :
}
}
/**
* NIP-22: a reply should be a kind 1111 Comment (instead of a kind 1 reply) when
* the note being replied to is a kind 1 [TextNoteEvent], is the root of a new
* thread, and was itself posted from Amethyst. Forks keep using kind 1.
*/
private fun shouldReplyAsComment(): Boolean {
if (forkedFromNote != null) return false
val replyingToEvent = originalNote?.event ?: return false
return replyingToEvent is TextNoteEvent &&
replyingToEvent.isNewThread() &&
replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME)
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiMedia>?,
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.ImageCompressionStore
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker
@@ -84,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip
import com.vitorpamplona.amethyst.desktop.ui.media.buildPreview
import com.vitorpamplona.amethyst.desktop.ui.media.cleanupPreviewTemps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
@@ -97,6 +99,8 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -805,22 +809,38 @@ private suspend fun publishNote(
}
val template =
TextNoteEvent.build(content) {
if (replyTo != null) {
val etag = ETag(replyTo.id)
etag.relay = null
etag.author = replyTo.pubKey
eTag(etag)
pTag(PTag(replyTo.pubKey, relayHint = null))
if (replyTo is TextNoteEvent && replyTo.isNewThread() && replyTo.isClient(DesktopIAccount.CLIENT_TAG_NAME)) {
// NIP-22: replying to a brand-new Amethyst kind-1 thread root produces
// a kind 1111 Comment instead of a kind 1 reply.
CommentEvent.replyBuilder(content, EventHintBundle(replyTo)) {
if (quoteOf != null) {
quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey))
pTag(PTag(quoteOf.pubKey, relayHint = null))
}
hashtags(findHashtags(content))
references(findURLs(content))
for (imeta in imetaTags) {
add(imeta.toTagArray())
}
}
if (quoteOf != null) {
quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey))
pTag(PTag(quoteOf.pubKey, relayHint = null))
}
hashtags(findHashtags(content))
references(findURLs(content))
for (imeta in imetaTags) {
add(imeta.toTagArray())
} else {
TextNoteEvent.build(content) {
if (replyTo != null) {
val etag = ETag(replyTo.id)
etag.relay = null
etag.author = replyTo.pubKey
eTag(etag)
pTag(PTag(replyTo.pubKey, relayHint = null))
}
if (quoteOf != null) {
quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey))
pTag(PTag(quoteOf.pubKey, relayHint = null))
}
hashtags(findHashtags(content))
references(findURLs(content))
for (imeta in imetaTags) {
add(imeta.toTagArray())
}
}
}
@@ -23,3 +23,6 @@ package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
fun Event.client() = tags.client()
/** True when this event was published by the client named [name] (NIP-89, case-insensitive). */
fun Event.isClient(name: String) = tags.isClient(name)
@@ -23,3 +23,6 @@ package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.client() = this.mapNotNull(ClientTag::parse)
/** True when any client tag (NIP-89) names [name] (case-insensitive). */
fun TagArray.isClient(name: String) = this.any { ClientTag.isTag(it) && it[1].equals(name, ignoreCase = true) }
@@ -0,0 +1,63 @@
/*
* 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.quartz.nip89AppHandlers.clientTag
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class IsClientTest {
@Test
fun matchesClientTagByName() {
val tags = arrayOf(arrayOf("client", "Amethyst"))
assertTrue(tags.isClient("Amethyst"))
}
@Test
fun matchesIgnoringCase() {
val tags = arrayOf(arrayOf("client", "amethyst"))
assertTrue(tags.isClient("Amethyst"))
}
@Test
fun matchesWithAddressAndRelayHint() {
val tags = arrayOf(arrayOf("client", "Amethyst", "31990:abc123:amethyst", "wss://relay.example.com"))
assertTrue(tags.isClient("Amethyst"))
}
@Test
fun doesNotMatchDifferentClient() {
val tags = arrayOf(arrayOf("client", "OtherClient"))
assertFalse(tags.isClient("Amethyst"))
}
@Test
fun doesNotMatchWhenNoClientTag() {
val tags = arrayOf(arrayOf("e", "abc"), arrayOf("p", "def"))
assertFalse(tags.isClient("Amethyst"))
}
@Test
fun doesNotMatchEmptyClientName() {
val tags = arrayOf(arrayOf("client", ""))
assertFalse(tags.isClient("Amethyst"))
}
}