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 3f4c9c6483..5f3cf8eb5d 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 @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 5eeec24635..96590b55e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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() ?: 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?, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 707275d5d2..fedaa0a6c5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -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()) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt index ca48ffadab..e8c68efaf7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt index 4ec759eaa3..f7ae9d439f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt @@ -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) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/IsClientTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/IsClientTest.kt new file mode 100644 index 0000000000..6c366541b4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/IsClientTest.kt @@ -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")) + } +}