mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
+1
@@ -566,5 +566,6 @@ private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
|
||||
blurhash = result.fileHeader.blurHash?.blurhash,
|
||||
cipher = cipher,
|
||||
originalHash = result.hashBeforeEncryption,
|
||||
thumbhash = result.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
+8
-2
@@ -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())
|
||||
|
||||
+4
@@ -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()}"))
|
||||
|
||||
Reference in New Issue
Block a user