mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
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:
@@ -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
|
||||
|
||||
+65
@@ -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,
|
||||
|
||||
+1
-20
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+57
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+19
@@ -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,
|
||||
|
||||
+31
@@ -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]
|
||||
|
||||
+34
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user