feat(media): encrypted file sharing in desktop DMs (NIP-17 Phase 6)
Add full send + receive encrypted media support in desktop DM chat: - Paperclip attach button and drag-and-drop in ChatPane (NIP-17 mode only) - AES-GCM encryption before upload to Blossom server - ChatMessageEncryptedFileHeaderEvent (kind 15) wrapped in GiftWrap - sendNip17EncryptedFile() added to IAccount interface and implementations - DesktopUploadOrchestrator.uploadEncrypted() with proper encrypted hash - DesktopBlossomClient ByteArray upload overload for encrypted blobs - LRU cache in EncryptedMediaService to avoid re-downloading - Error handling: retry on failure, disable send during upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+32
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
@@ -156,6 +157,37 @@ class DesktopIAccount(
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
|
||||
// Optimistic local add
|
||||
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
|
||||
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
|
||||
|
||||
// Collect wraps with target relays and send
|
||||
val batch =
|
||||
result.wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays =
|
||||
if (recipientKey != null) {
|
||||
val dmRelays =
|
||||
localCache
|
||||
.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelays()
|
||||
?.toSet()
|
||||
dmRelays?.ifEmpty { null }
|
||||
?: relayManager.connectedRelays.value
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
|
||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||
val batch =
|
||||
wraps.map { wrap ->
|
||||
|
||||
+19
-3
@@ -25,24 +25,31 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Handles decryption of media files for NIP-17 DMs.
|
||||
* Uses AESGCM cipher from quartz (commonMain).
|
||||
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
|
||||
*/
|
||||
object EncryptedMediaService {
|
||||
private val httpClient = OkHttpClient()
|
||||
private val cache = ConcurrentHashMap<String, ByteArray>()
|
||||
private const val MAX_CACHE_ENTRIES = 20
|
||||
|
||||
/**
|
||||
* Download and decrypt an encrypted file from a URL.
|
||||
* Results are cached by URL to avoid re-downloading on scroll/recomposition.
|
||||
* Returns the decrypted bytes.
|
||||
*/
|
||||
suspend fun downloadAndDecrypt(
|
||||
url: String,
|
||||
keyBytes: ByteArray,
|
||||
nonce: ByteArray,
|
||||
): ByteArray =
|
||||
withContext(Dispatchers.IO) {
|
||||
): ByteArray {
|
||||
cache[url]?.let { return it }
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val encryptedBytes =
|
||||
@@ -52,6 +59,15 @@ object EncryptedMediaService {
|
||||
}
|
||||
|
||||
val cipher = AESGCM(keyBytes, nonce)
|
||||
cipher.decrypt(encryptedBytes)
|
||||
val decrypted = cipher.decrypt(encryptedBytes)
|
||||
|
||||
// Evict oldest entries if cache is full
|
||||
if (cache.size >= MAX_CACHE_ENTRIES) {
|
||||
cache.keys.firstOrNull()?.let { cache.remove(it) }
|
||||
}
|
||||
cache[url] = decrypted
|
||||
|
||||
decrypted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -28,6 +28,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
@@ -62,6 +63,37 @@ class DesktopBlossomClient(
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
val reason = it.headers["X-Reason"] ?: it.code.toString()
|
||||
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
|
||||
*/
|
||||
suspend fun upload(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||
val requestBody = bytes.toRequestBody(contentType.toMediaType())
|
||||
|
||||
val requestBuilder =
|
||||
Request
|
||||
.Builder()
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
|
||||
+58
@@ -20,8 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import java.io.File
|
||||
|
||||
data class UploadResult(
|
||||
@@ -29,6 +32,13 @@ data class UploadResult(
|
||||
val metadata: MediaMetadata,
|
||||
)
|
||||
|
||||
data class EncryptedUploadResult(
|
||||
val blossom: BlossomUploadResult,
|
||||
val metadata: MediaMetadata,
|
||||
val encryptedHash: String,
|
||||
val encryptedSize: Int,
|
||||
)
|
||||
|
||||
class DesktopUploadOrchestrator(
|
||||
private val client: DesktopBlossomClient = DesktopBlossomClient(),
|
||||
) {
|
||||
@@ -75,4 +85,52 @@ class DesktopUploadOrchestrator(
|
||||
|
||||
return UploadResult(blossom = result, metadata = metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file encrypted with AES-GCM for NIP-17 DM file sharing.
|
||||
* Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes,
|
||||
* then uploads the encrypted blob to Blossom.
|
||||
*/
|
||||
suspend fun uploadEncrypted(
|
||||
file: File,
|
||||
cipher: AESGCM,
|
||||
serverBaseUrl: String,
|
||||
signer: NostrSigner,
|
||||
): EncryptedUploadResult {
|
||||
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
|
||||
val metadata = DesktopMediaMetadata.compute(file)
|
||||
|
||||
// 2. Read file bytes and encrypt
|
||||
val plaintext = file.readBytes()
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
|
||||
// 3. Compute SHA256 of ENCRYPTED blob (not plaintext)
|
||||
val encryptedHash = sha256(encrypted).toHexKey()
|
||||
val encryptedSize = encrypted.size
|
||||
|
||||
// 4. Create Blossom auth with encrypted hash and size
|
||||
val authHeader =
|
||||
DesktopBlossomAuth.createUploadAuth(
|
||||
hash = encryptedHash,
|
||||
size = encryptedSize.toLong(),
|
||||
alt = "Encrypted upload",
|
||||
signer = signer,
|
||||
)
|
||||
|
||||
// 5. Upload encrypted blob as opaque binary
|
||||
val result =
|
||||
client.upload(
|
||||
bytes = encrypted,
|
||||
contentType = "application/octet-stream",
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
|
||||
return EncryptedUploadResult(
|
||||
blossom = result,
|
||||
metadata = metadata,
|
||||
encryptedHash = encryptedHash,
|
||||
encryptedSize = encryptedSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+179
-5
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -38,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
@@ -53,6 +56,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -60,6 +64,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
@@ -88,15 +94,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.io.File
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
private val MEDIA_EXTENSIONS =
|
||||
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
|
||||
|
||||
/**
|
||||
* Right panel of the DM split-pane layout (flexible width).
|
||||
*
|
||||
@@ -112,6 +131,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
* @param messageState ChatNewMessageState for composition
|
||||
* @param onNavigateToProfile Called when user clicks on a profile
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ChatPane(
|
||||
roomKey: ChatroomKey,
|
||||
@@ -129,6 +149,41 @@ fun ChatPane(
|
||||
val isNip17 by messageState.nip17.collectAsState()
|
||||
val requiresNip17 by messageState.requiresNip17.collectAsState()
|
||||
|
||||
// File attachment state
|
||||
val attachedFiles = remember { mutableStateListOf<File>() }
|
||||
var isUploading by remember { mutableStateOf(false) }
|
||||
|
||||
// Drag-and-drop target for file attachments (NIP-17 only)
|
||||
var isDragOver by remember { mutableStateOf(false) }
|
||||
val dropTarget =
|
||||
remember {
|
||||
object : DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
isDragOver = false
|
||||
val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false
|
||||
dropEvent.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val transferable = dropEvent.transferable
|
||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
||||
attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS })
|
||||
dropEvent.dropComplete(true)
|
||||
return true
|
||||
}
|
||||
dropEvent.dropComplete(false)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onStarted(event: DragAndDropEvent) {
|
||||
isDragOver = true
|
||||
}
|
||||
|
||||
override fun onEnded(event: DragAndDropEvent) {
|
||||
isDragOver = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve users for the header
|
||||
val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
|
||||
val isGroup = users.size > 1
|
||||
@@ -138,7 +193,21 @@ fun ChatPane(
|
||||
messageState.load(roomKey)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.dragAndDropTarget(
|
||||
shouldStartDragAndDrop = { isNip17 },
|
||||
target = dropTarget,
|
||||
).then(
|
||||
if (isDragOver) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
// Header
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
@@ -225,17 +294,59 @@ fun ChatPane(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// File attachment row (only when NIP-17 and files attached)
|
||||
if (isNip17 && attachedFiles.isNotEmpty()) {
|
||||
MediaAttachmentRow(
|
||||
attachedFiles = attachedFiles,
|
||||
isUploading = isUploading,
|
||||
onAttach = {
|
||||
val files = DesktopFilePicker.pickMediaFiles()
|
||||
attachedFiles.addAll(files)
|
||||
},
|
||||
onPaste = {},
|
||||
onRemove = { attachedFiles.remove(it) },
|
||||
)
|
||||
}
|
||||
|
||||
// Message input
|
||||
MessageInput(
|
||||
messageText = messageText.text,
|
||||
isNip17 = isNip17,
|
||||
requiresNip17 = requiresNip17,
|
||||
canSend = messageState.canSend,
|
||||
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
|
||||
isUploading = isUploading,
|
||||
hasAttachments = attachedFiles.isNotEmpty(),
|
||||
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
||||
onToggleNip17 = { messageState.toggleNip17() },
|
||||
onAttach = {
|
||||
val files = DesktopFilePicker.pickMediaFiles()
|
||||
attachedFiles.addAll(files)
|
||||
},
|
||||
onSend = {
|
||||
scope.launch {
|
||||
if (messageState.send()) {
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
isUploading = true
|
||||
try {
|
||||
sendEncryptedFiles(
|
||||
files = attachedFiles.toList(),
|
||||
roomKey = roomKey,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
)
|
||||
attachedFiles.clear()
|
||||
} catch (e: Exception) {
|
||||
// Keep files in attachment row for retry
|
||||
println("Encrypted file send failed: ${e.message}")
|
||||
} finally {
|
||||
isUploading = false
|
||||
}
|
||||
}
|
||||
// Also send text message if present
|
||||
if (messageState.canSend) {
|
||||
if (messageState.send()) {
|
||||
messageState.clear()
|
||||
}
|
||||
} else if (attachedFiles.isEmpty()) {
|
||||
messageState.clear()
|
||||
}
|
||||
}
|
||||
@@ -529,8 +640,11 @@ private fun MessageInput(
|
||||
isNip17: Boolean,
|
||||
requiresNip17: Boolean,
|
||||
canSend: Boolean,
|
||||
isUploading: Boolean = false,
|
||||
hasAttachments: Boolean = false,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onToggleNip17: () -> Unit,
|
||||
onAttach: () -> Unit = {},
|
||||
onSend: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
@@ -539,6 +653,26 @@ private fun MessageInput(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Paperclip attach button (NIP-17 only)
|
||||
if (isNip17) {
|
||||
IconButton(
|
||||
onClick = onAttach,
|
||||
enabled = !isUploading,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach file",
|
||||
tint =
|
||||
if (isUploading) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = messageText,
|
||||
onValueChange = onMessageChange,
|
||||
@@ -564,14 +698,14 @@ private fun MessageInput(
|
||||
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
enabled = canSend && !isUploading,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint =
|
||||
if (canSend) {
|
||||
if (canSend && !isUploading) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
@@ -625,3 +759,43 @@ private fun MessageInput(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15)
|
||||
* wrapped in GiftWrap for each recipient.
|
||||
*/
|
||||
private suspend fun sendEncryptedFiles(
|
||||
files: List<File>,
|
||||
roomKey: ChatroomKey,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
) {
|
||||
val orchestrator = DesktopUploadOrchestrator()
|
||||
val server = DesktopPreferences.preferredBlossomServer
|
||||
val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() }
|
||||
|
||||
for (file in files) {
|
||||
val cipher = AESGCM()
|
||||
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
|
||||
val url = result.blossom.url ?: continue
|
||||
|
||||
val template =
|
||||
ChatMessageEncryptedFileHeaderEvent.build(
|
||||
to = recipients,
|
||||
url = url,
|
||||
cipher = cipher,
|
||||
mimeType = result.metadata.mimeType,
|
||||
hash = result.encryptedHash,
|
||||
size = result.encryptedSize,
|
||||
dimension =
|
||||
if (result.metadata.width != null && result.metadata.height != null) {
|
||||
DimensionTag(result.metadata.width, result.metadata.height)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
blurhash = result.metadata.blurhash,
|
||||
originalHash = result.metadata.sha256,
|
||||
)
|
||||
account.sendNip17EncryptedFile(template)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user