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()}"))