feat(minichat): allow sending pictures in thread replies

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1
This commit is contained in:
Claude
2026-07-24 21:36:05 +00:00
parent 5d72a0415c
commit 6f40d997c6
8 changed files with 246 additions and 27 deletions
@@ -2206,6 +2206,7 @@ class Account(
text: String,
replyTo: Note? = null,
replyMode: ReplyMode = ReplyMode.INLINE,
imetas: List<IMetaTag> = 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<IMetaTag> = 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<IMetaTag>,
): 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
@@ -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,
@@ -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,
)
}
@@ -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<SuccessfulUploads>.toPlainImetas(): List<IMetaTag> {
val attachments = IMetaAttachments()
forEach { attachments.add(it.result, it.caption, it.contentWarningReason) }
return attachments.iMetaAttachments
}
+2
View File
@@ -417,6 +417,8 @@
<string name="chat_reply_in_thread">In thread</string>
<!-- Title of the minichat (thread) screen opened from a chat message. -->
<string name="chat_minichat_title">Thread</string>
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
<string name="chat_send_image_title">Send image</string>
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
<plurals name="chat_minichat_reply_count">
<item quantity="one">%1$d reply</item>
@@ -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<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = 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,
@@ -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<IMetaTag>,
parent: Event,
createdAt: Long,
extraTags: Array<Array<String>> = 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]
@@ -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 {