Merge pull request #3573 from vitorpamplona/claude/concord-image-display-ou01vs

Support NIP-92 imeta attachments in chat messages
This commit is contained in:
Vitor Pamplona
2026-07-15 11:11:41 -04:00
committed by GitHub
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()}"))