From 092c057e6f5fb3683d6c9081ee90d56245ff54c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 01:17:28 +0000 Subject: [PATCH] feat: implement NIP-C7 chat messages (kind 9) Add ChatEvent in quartz/nipC7Chats with support for kind 9 chat messages and q-tag based replies per the NIP-C7 specification. Wire up rendering in NoteCompose and NoteMaster (ThreadFeedView). https://claude.ai/code/session_01BmAMHBCRKXG612i6zrhhP4 --- .../amethyst/model/LocalCache.kt | 8 ++ .../amethyst/ui/note/NoteCompose.kt | 14 +++ .../amethyst/ui/note/types/Chat.kt | 92 ++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 14 ++- .../quartz/nipC7Chats/ChatEvent.kt | 113 ++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC7Chats/ChatEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 4a7375179..97c9e4234 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -217,6 +217,7 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -2156,6 +2157,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: ChatEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( event: PollEvent, relay: NormalizedRelayUrl?, @@ -3236,6 +3243,7 @@ object LocalCache : ILocalCache, ICacheProvider { is RequestToVanishEvent -> consume(event, relay, wasVerified) is CodeSnippetEvent -> consume(event, relay, wasVerified) is ZapPollEvent -> consume(event, relay, wasVerified) + is ChatEvent -> consume(event, relay, wasVerified) is PollEvent -> consume(event, relay, wasVerified) is PollResponseEvent -> consume(event, relay, wasVerified) is RelayDiscoveryEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 6330fa558..aa1518e82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -116,6 +116,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChat import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame @@ -288,6 +289,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -1178,6 +1180,18 @@ private fun RenderNoteRow( ) } + is ChatEvent -> { + RenderChat( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + is PollEvent -> { RenderPoll( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt new file mode 100644 index 000000000..a499e2850 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt @@ -0,0 +1,92 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags + +@Composable +fun RenderChat( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event ?: return + val eventContent = remember(noteEvent) { noteEvent.content } + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val tags = + remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + val callbackUri = remember(note) { note.toNostrUri() } + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 11ac8e9fc..a9cd2a2fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -149,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChat import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -162,8 +163,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap -import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -286,6 +287,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -775,6 +777,16 @@ private fun FullBleedNoteCompose( RenderFedimint(noteEvent) } else if (noteEvent is MintRecommendationEvent) { RenderMintRecommendation(noteEvent) + } else if (noteEvent is ChatEvent) { + RenderChat( + note = baseNote, + makeItShort = false, + canPreview = canPreview, + quotesLeft = 3, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } else if (noteEvent is PollEvent) { RenderPoll( note = baseNote, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC7Chats/ChatEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC7Chats/ChatEvent.kt new file mode 100644 index 000000000..523b032fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC7Chats/ChatEvent.kt @@ -0,0 +1,113 @@ +/* + * 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.nipC7Chats + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.quote +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds +import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints +import com.vitorpamplona.quartz.nip19Bech32.pubKeys +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ChatEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope, + EventHintProvider, + PubKeyHintProvider, + SearchableEvent { + override fun indexableContent() = content + + fun quotedEvents() = tags.mapNotNull(QEventTag::parse) + + fun replyingTo() = tags.lastOrNull { it.size > 1 && it[0] == QTag.TAG_NAME }?.get(1) + + override fun eventHints(): List { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + return qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + return qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + val pHints = tags.mapNotNull(PTag::parseKey) + val nip19Hints = citedNIP19().pubKeys() + return pHints + nip19Hints + } + + companion object { + const val KIND = 9 + const val ALT_DESCRIPTION = "Chat message" + + fun build( + message: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + alt(ALT_DESCRIPTION) + initializer() + } + + fun reply( + message: String, + replyTo: EventHintBundle, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + alt(ALT_DESCRIPTION) + quote(QEventTag(replyTo.event.id, replyTo.relay, replyTo.event.pubKey)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a149f84a5..1f7ba76ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -186,6 +186,7 @@ import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent interface EventBuilder { fun build( @@ -239,6 +240,7 @@ class EventFactory { CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(id, pubKey, createdAt, tags, content, sig) CashuWalletEvent.KIND -> CashuWalletEvent(id, pubKey, createdAt, tags, content, sig) + ChatEvent.KIND -> ChatEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig)