From bb9f0c0cb33cc43302ccd9b4544e065680859415 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:54:48 +0000 Subject: [PATCH] fix(concord): render imeta-only chat images and send thumbhash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord/NIP-C7 image messages from interop clients (e.g. Ditto/Soapbox Armada) attach the image purely as a NIP-92 `imeta` tag and leave the message content empty. Amethyst's shared renderer only shows media whose URL appears in the text, so those images never rendered — even though the encrypted blob's key/nonce were already registered for transparent decryption at ingest. RenderChat now appends any imeta URL missing from the content before handing it to the media renderer (symmetric to ChannelChat.imageMessage, which appends the URL on send), so imeta-only attachments display. Also forward the thumbhash on the encrypted-image imeta: FileHeader computes both blurhash and thumbhash, but only blurhash was being sent. encryptedImageImeta now emits an additive `thumbhash` field and toConcordImeta forwards the computed value, so receivers can paint a placeholder while the blob decrypts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016TGjBiJL2fvTR37eePBKrG --- .../amethyst/ui/note/types/Chat.kt | 24 ++++++++++++++++++- .../concord/ConcordChannelScreen.kt | 1 + .../concord/cord03Channels/ChannelChat.kt | 10 ++++++-- .../cord03Channels/ChannelChatEndToEndTest.kt | 4 ++++ 4 files changed, 36 insertions(+), 3 deletions(-) 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 index 53c00eaa9a..c516dc6b9a 100644 --- 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 @@ -41,8 +41,10 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip92IMeta.imetas @Composable fun RenderChat( @@ -58,6 +60,13 @@ fun RenderChat( ) { val noteEvent = note.event ?: return val eventContent = remember(noteEvent) { noteEvent.content } + // Content actually handed to the media renderer. The shared renderer only shows media whose URL + // appears in the text, but some NIP-C7/Concord clients (e.g. Ditto/Soapbox Armada) attach an + // image purely as a NIP-92 `imeta` tag and leave the content empty. Append any imeta URL missing + // from the content so those attachments render — symmetric to ChannelChat.imageMessage, which + // appends the URL on send. Encrypted-blob key/nonce are already registered by URL, so the shared + // pipeline fetches and decrypts them transparently. + val displayContent = remember(noteEvent) { appendMissingImetaUrls(noteEvent) } // A boosted note inside a zap/nutzap/onchain activity card is always shown as a // compact 2-line preview, even when the logged-in user is only a zap-split @@ -101,7 +110,7 @@ fun RenderChat( remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } TranslatableRichTextViewer( - content = eventContent, + content = displayContent, canPreview = canPreview && !makeItShort, quotesLeft = quotesLeft, modifier = Modifier.fillMaxWidth(), @@ -120,3 +129,16 @@ fun RenderChat( } } } + +/** + * Returns [event]'s content with every NIP-92 `imeta` URL that is not already present appended on its + * own line, so an attachment carried only as an `imeta` tag (empty/incomplete content) still renders. + * Returns the original content unchanged when every imeta URL is already inline (the common case), so + * normal messages are untouched. + */ +private fun appendMissingImetaUrls(event: Event): String { + val content = event.content + val missing = event.imetas().map { it.url }.filter { it.isNotBlank() && !content.contains(it) } + if (missing.isEmpty()) return content + return (listOf(content) + missing).filter { it.isNotBlank() }.joinToString("\n") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index c18bd22474..764961ace3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -566,5 +566,6 @@ private fun SuccessfulUploads.toConcordImeta(): IMetaTag? { blurhash = result.fileHeader.blurHash?.blurhash, cipher = cipher, originalHash = result.hashBeforeEncryption, + thumbhash = result.fileHeader.thumbHash?.thumbhash, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 9c4f79135e..1752ab9172 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -35,7 +35,9 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.ciphers.AESGCM @@ -198,7 +200,9 @@ object ChannelChat { * Builds the encrypted-image `imeta` tag Armada's `ChatComposer` emits with `encryptAttachments`: * `url` (ciphertext blob), `m` (plaintext mime), `dim`, `blurhash`, plus `encryption-algorithm` * (`aes-gcm`), `decryption-key`, `decryption-nonce` (hex), and `ox` (the *plaintext* SHA-256 for - * integrity). Deliberately omits `x` (a ciphertext hash) to match Armada exactly. + * integrity). Deliberately omits `x` (a ciphertext hash) to match Armada exactly. When a + * [thumbhash] is available it is added as an extra (additive, ignored by clients that don't read + * it) so receivers can paint a placeholder while the blob decrypts. */ fun encryptedImageImeta( url: String, @@ -207,12 +211,14 @@ object ChannelChat { blurhash: String?, cipher: AESGCM, originalHash: String?, + thumbhash: String? = null, ): IMetaTag = IMetaTagBuilder(url) .apply { mimeType?.let { add("m", it) } dim?.let { add("dim", it) } - blurhash?.let { add("blurhash", it) } + blurhash?.let { add(BlurhashTag.TAG_NAME, it) } + thumbhash?.let { add(ThumbhashTag.TAG_NAME, it) } add(EncryptionAlgo.TAG_NAME, cipher.name()) add(EncryptionKey.TAG_NAME, cipher.keyBytes.toHexKey()) add(EncryptionNonce.TAG_NAME, cipher.nonce.toHexKey()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index 0c371e1749..bf814a59a7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -135,6 +135,7 @@ class ChannelChatEndToEndTest { blurhash = "LKO2", cipher = cipher, originalHash = ox, + thumbhash = "abc123", ) val msg = ChannelChat.imageMessage(author, channelIdHex, 0L, "look", listOf(imeta), createdAt = 5L) @@ -148,6 +149,9 @@ class ChannelChatEndToEndTest { assertTrue(imetaTag.contains("url $url")) assertTrue(imetaTag.contains("m image/jpeg")) assertTrue(imetaTag.contains("dim 800x600")) + assertTrue(imetaTag.contains("blurhash LKO2")) + // Thumbhash rides as an additive imeta field so receivers can paint a placeholder. + assertTrue(imetaTag.contains("thumbhash abc123")) assertTrue(imetaTag.contains("encryption-algorithm aes-gcm")) assertTrue(imetaTag.contains("decryption-key ${ByteArray(32) { 0x11 }.toHexKey()}")) assertTrue(imetaTag.contains("decryption-nonce ${ByteArray(16) { 0x22 }.toHexKey()}"))