From 6f40d997c656ef9f72ad398bd3ace0f76f402c7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:36:05 +0000 Subject: [PATCH] feat(minichat): allow sending pictures in thread replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chat composer in Amethyst already had a picture/media attach button (SelectFromGallery) except the minichat "thread" screen — the kind-1111 reply composer opened from the "N replies" chip — which was text-only. This brings it to parity with every other chat. - quartz: ChannelChat.imageReply() — a kind-1111 thread reply carrying encrypted image imeta(s), combining reply()'s NIP-22 pointers with imageMessage()'s ciphertext-URL/imeta handling (+ round-trip test). - commons: ConcordActions.buildChannelImageReply(). - Account.sendMinichatReply() now accepts imetas and routes per backend: Concord sends an encrypted image reply; NIP-28/NIP-29 public chats append the URL to the content and carry a plaintext imeta on the comment; Buzz appends the URL to the stream message content. - Extract toConcordImeta()/toPlainImetas() into a shared UploadImetas.kt so the minichat and Concord composers build imeta the same way. - MinichatScreen: add the SelectFromGallery leading icon + ChatFileUpload dialog, encrypting only when the backend is end-to-end (Concord). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1 --- .../vitorpamplona/amethyst/model/Account.kt | 44 +++++++++++-- .../loggedIn/chats/minichat/MinichatScreen.kt | 65 +++++++++++++++++++ .../concord/ConcordChannelScreen.kt | 21 +----- .../loggedIn/chats/utils/UploadImetas.kt | 57 ++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../commons/actions/ConcordActions.kt | 19 ++++++ .../concord/cord03Channels/ChannelChat.kt | 31 +++++++++ .../cord03Channels/ChannelChatEndToEndTest.kt | 34 ++++++++++ 8 files changed, 246 insertions(+), 27 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a5102f3d39..44bbedb304 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2206,6 +2206,7 @@ class Account( text: String, replyTo: Note? = null, replyMode: ReplyMode = ReplyMode.INLINE, + imetas: List = emptyList(), ): Boolean { if (!isWriteable()) return false val session = concordSessions.sessionFor(communityId) ?: return false @@ -2219,8 +2220,11 @@ class Account( val parent = replyTo?.event val wrap = when { - // A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9 - // message quoting the parent; a fresh post is a plain kind-9 message. + // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when + // the user attached media); an inline reply is a kind-9 message quoting the parent; a + // fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> + ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) parent != null && replyMode == ReplyMode.MINICHAT -> ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) parent != null -> @@ -2267,6 +2271,7 @@ class Account( suspend fun sendMinichatReply( rootNote: Note, text: String, + imetas: List = emptyList(), ): Boolean { if (!isWriteable()) return false val gatherers = rootNote.inGatherers @@ -2278,16 +2283,24 @@ class Account( text, rootNote, ReplyMode.MINICHAT, + imetas, ) } // Public chats: a plain public kind-1111 comment rooted at the message. NIP-29 groups - // additionally carry the `h` tag and go only to the host relay. + // additionally carry the `h` tag and go only to the host relay. Attached media rides as + // NIP-92 `imeta` tags, with each URL appended to the content so any client renders it. val rootEvent = rootNote.event ?: return false + val finalText = appendMediaUrls(text, imetas) gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat -> val relays = chat.relays() - val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull()))) + val signed = + signer.sign( + CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, relays.firstOrNull())) { + imetas(imetas) + }, + ) cache.justConsumeMyOwnEvent(signed) client.publish(signed, relays.ifEmpty { outboxRelays.flow.value }) return true @@ -2298,10 +2311,11 @@ class Account( val signed = if (BuzzRelayDialect.isBuzz(hostRelay)) { // Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's - // root (never `broadcast` — a minichat reply always lives in the thread). + // root (never `broadcast` — a minichat reply always lives in the thread). Attached + // media is carried as URLs appended to the content (no `imeta` on the stream event). val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id signer.sign( - StreamMessageV2Event.build(group.groupId.id, text) { + StreamMessageV2Event.build(group.groupId.id, finalText) { buzzThread(root, rootEvent.id) rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } previous(group.previousEventRefs(pubKey)) @@ -2309,9 +2323,10 @@ class Account( ) } else { signer.sign( - CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { + CommentEvent.replyBuilder(finalText, EventHintBundle(rootEvent, hostRelay)) { hTag(group.groupId.id) previous(group.previousEventRefs(pubKey)) + imetas(imetas) }, ) } @@ -2323,6 +2338,21 @@ class Account( return false } + /** + * Appends each attachment URL not already present in [text] to the message content (newline + * separated), so a plaintext media link renders inline in any client — mirroring + * [com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat.imageMessage]. Returns [text] + * unchanged when there are no attachments. + */ + private fun appendMediaUrls( + text: String, + imetas: List, + ): String { + if (imetas.isEmpty()) return text + val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } + return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") + } + /** * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 0189c58e3e..6f15e93574 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -24,6 +24,7 @@ import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -48,6 +49,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -56,16 +58,22 @@ import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toPlainImetas import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier @@ -136,6 +144,13 @@ fun MinichatScreen( val scope = rememberCoroutineScope() val context = LocalContext.current val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } + val uploadState = + remember { + ChatFileUploadState( + accountViewModel.account.settings.defaultFileServer, + accountViewModel.account.settings.stripLocationOnUpload, + ) + } Scaffold( topBar = { @@ -188,6 +203,43 @@ fun MinichatScreen( } } + // Picture attachments: the picked media opens this dialog, which uploads via the shared + // pipeline and sends the result as a thread reply. Concord minichats always encrypt (the + // channel is end-to-end); public-chat (NIP-28/NIP-29) minichats upload in plaintext and carry + // the URL + `imeta` on the kind-1111 comment. [Account.sendMinichatReply] routes by backend. + uploadState.multiOrchestrator?.let { + ChatFileUploadDialog( + state = uploadState, + title = { Text(stringRes(R.string.chat_send_image_title)) }, + upload = { + scope.launch(Dispatchers.IO) { + ChatFileUploader(accountViewModel.account).justUploadNIP17( + viewState = uploadState, + onError = { title, message -> + launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + }, + onEncryptedUploadError = { title, message -> + launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + }, + context = context, + onceUploaded = { uploads -> + val caption = uploadState.caption + val imetas = if (isConcord) uploads.mapNotNull { it.toConcordImeta() } else uploads.toPlainImetas() + if (imetas.isNotEmpty()) { + accountViewModel.account.sendMinichatReply(rootNote, caption, imetas) + } + }, + ) + accountViewModel.account.settings.changeDefaultFileServer(uploadState.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(uploadState.stripMetadata) + } + }, + onCancel = uploadState::reset, + accountViewModel = accountViewModel, + nav = nav, + ) + } + Column(modifier = EditFieldModifier) { ThinPaddingTextField( state = composer, @@ -199,6 +251,19 @@ fun MinichatScreen( color = MaterialTheme.colorScheme.placeholderText, ) }, + leadingIcon = { + SelectFromGallery( + isUploading = uploadState.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.height(32.dp).padding(start = 2.dp), + onImageChosen = { media -> + uploadState.load(media) + // Encrypt only where the backend is end-to-end (Concord); public chats + // send plaintext blobs, matching how their main composer uploads. + uploadState.encryptFiles = isConcord + }, + ) + }, trailingIcon = { ThinSendButton( isActive = canPost, 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 764961ace3..6962647b2b 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 @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadS import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder @@ -95,11 +96,9 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -551,21 +550,3 @@ private fun ConcordFileUploadDialog( nav = nav, ) } - -/** - * Turns an encrypted upload into the Armada-shaped `imeta` (via [ChannelChat.encryptedImageImeta]). - * Returns null when the upload carried no cipher — so a non-encrypted blob is never sent as a Concord - * image (fails closed, protecting the community's end-to-end guarantee). - */ -private fun SuccessfulUploads.toConcordImeta(): IMetaTag? { - val cipher = cipher ?: return null - return ChannelChat.encryptedImageImeta( - url = result.url, - mimeType = result.mimeTypeBeforeEncryption, - dim = result.fileHeader.dim?.toString(), - blurhash = result.fileHeader.blurHash?.blurhash, - cipher = cipher, - originalHash = result.hashBeforeEncryption, - thumbhash = result.fileHeader.thumbHash?.thumbhash, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt new file mode 100644 index 0000000000..17ab40f078 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/UploadImetas.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag + +/** + * Turns an encrypted upload into the Armada-shaped encrypted `imeta` (via + * [ChannelChat.encryptedImageImeta]). Returns null when the upload carried no cipher — so a + * non-encrypted blob is never sent as an encrypted image (fails closed, protecting the end-to-end + * guarantee of Concord channels and their minichat threads). + */ +fun SuccessfulUploads.toConcordImeta(): IMetaTag? { + val cipher = cipher ?: return null + return ChannelChat.encryptedImageImeta( + url = result.url, + mimeType = result.mimeTypeBeforeEncryption, + dim = result.fileHeader.dim?.toString(), + blurhash = result.fileHeader.blurHash?.blurhash, + cipher = cipher, + originalHash = result.hashBeforeEncryption, + thumbhash = result.fileHeader.thumbHash?.thumbhash, + ) +} + +/** + * Turns a list of plaintext (unencrypted) uploads into their NIP-92 `imeta` tags — the shape a + * public-chat (NIP-28/NIP-29) message carries. Reuses [IMetaAttachments.add] so the tag content + * (hash, size, mime, dims, blurhash, thumbhash, magnet, alt, content-warning) matches every other + * plaintext composer. + */ +fun List.toPlainImetas(): List { + val attachments = IMetaAttachments() + forEach { attachments.add(it.result, it.caption, it.contentWarningReason) } + return attachments.iMetaAttachments +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2052e7b286..0e04dfe5d0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -417,6 +417,8 @@ In thread Thread + + Send image %1$d reply diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 4847685fa0..10e9467de9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -255,6 +255,25 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an encrypted-seal thread-reply wrap carrying one or more encrypted image [imetas] + * (kind-1111 NIP-22 comment on [parent]) on the [channel] plane — the minichat image path. + */ + suspend fun buildChannelImageReply( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + parent: Event, + text: String, + imetas: List, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.imageReply(authorSigner.pubKey, channelId, epoch, text, imetas, parent, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ suspend fun buildChannelReaction( authorSigner: NostrSigner, 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 1752ab9172..c1e83a7166 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 @@ -133,6 +133,37 @@ object ChannelChat { }, ) + /** + * Builds an unsigned kind-1111 **thread reply carrying encrypted image** attachments ([imetas]) to + * [parent], bound to [channelId]/[epoch]. Combines [reply]'s NIP-22 thread pointers with + * [imageMessage]'s attachment handling: each ciphertext URL not already in [text] is appended to the + * content (so the shared feed renders it) and each rides as a NIP-92 `imeta` tag + * ([encryptedImageImeta]). This is the minichat counterpart of [imageMessage] — an image sent as a + * thread reply instead of a top-level channel message. + */ + fun imageReply( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + imetas: List, + parent: Event, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } + val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") + return reply( + authorPubKey = authorPubKey, + channelId = channelId, + epoch = epoch, + text = finalText, + parent = parent, + createdAt = createdAt, + extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ) + } + /** * Builds an unsigned kind-7 [ReactionEvent] rumor bound to [channelId]/[epoch] * against the target message ([targetId]/[targetAuthor]/[targetKind]). [content] 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 bf814a59a7..3bf210b467 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 @@ -173,6 +173,40 @@ class ChannelChatEndToEndTest { assertTrue(ChannelChat.encryptedImagesOf(ChannelChat.message(author, channelIdHex, 0L, "hi", 1L)).isEmpty()) } + @Test + fun encryptedImageReplyIsAKind1111CommentCarryingTheEncryptedAttachment() { + val author = KeyPair().pubKey.toHexKey() + val parent = ChannelChat.message(author, channelIdHex, 0L, "root", createdAt = 1L) + val cipher = AESGCM(ByteArray(32) { 0x33 }, ByteArray(16) { 0x44 }) + val url = "https://blossom.example/reply-ciphertext.bin" + + val imeta = + ChannelChat.encryptedImageImeta( + url = url, + mimeType = "image/png", + dim = "640x480", + blurhash = null, + cipher = cipher, + originalHash = "bb".repeat(32), + ) + val reply = ChannelChat.imageReply(author, channelIdHex, 0L, "here", listOf(imeta), parent, createdAt = 6L) + + // A thread reply keeps the kind-1111 NIP-22 shape (E root + e parent), stays channel-bound, + // and appends the ciphertext url to content — exactly like [imageMessage] but on a comment. + assertEquals(1111, reply.kind) + assertEquals(parent.id, reply.tags.first { it[0] == "E" }[1]) + assertEquals(parent.id, reply.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(reply, channelIdHex, 0L)) + assertEquals("here\n$url", reply.content) + + // The encrypted attachment round-trips with the same key/nonce so the receiver can decrypt. + val parsed = ChannelChat.encryptedImagesOf(reply) + assertEquals(1, parsed.size) + assertEquals(url, parsed.first().url) + assertTrue(parsed.first().key.contentEquals(ByteArray(32) { 0x33 })) + assertTrue(parsed.first().nonce.contentEquals(ByteArray(16) { 0x44 })) + } + @Test fun nonMembersCannotDeriveThePlane() = runTest {