mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3573 from vitorpamplona/claude/concord-image-display-ou01vs
Support NIP-92 imeta attachments in chat messages
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