fix(concord): render imeta-only chat images and send thumbhash

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TGjBiJL2fvTR37eePBKrG
This commit is contained in:
Claude
2026-07-15 14:54:48 +00:00
parent dfde5336f1
commit bb9f0c0cb3
4 changed files with 36 additions and 3 deletions
@@ -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")
}
@@ -566,5 +566,6 @@ private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
blurhash = result.fileHeader.blurHash?.blurhash,
cipher = cipher,
originalHash = result.hashBeforeEncryption,
thumbhash = result.fileHeader.thumbHash?.thumbhash,
)
}
@@ -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())
@@ -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()}"))