From cd7ee69f506c55eb3a2420337b3f937353f37703 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 12:55:30 +0200 Subject: [PATCH] feat(media): add imeta tags on upload and drag-and-drop - Build IMetaTag (NIP-92) from upload metadata (mime, sha256, dim, blurhash, size) - Use TextNoteEvent.build directly with tag initializer for imeta injection - Add drag-and-drop file support via Compose DragAndDropTarget - Filter dropped files by media extension whitelist - Visual border highlight on drag-over state Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 125 ++++++++++++++++-- 1 file changed, 114 insertions(+), 11 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index ac63c4e07..893361045 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme @@ -42,29 +45,50 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +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.unit.dp import androidx.compose.ui.window.Dialog -import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker +import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTargetDropEvent import java.io.File +private val MEDIA_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ComposeNoteDialog( onDismiss: () -> Unit, relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, - replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null, + replyTo: Event? = null, ) { var content by remember { mutableStateOf("") } var isPosting by remember { mutableStateOf(false) } @@ -75,9 +99,51 @@ fun ComposeNoteDialog( val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { DesktopUploadOrchestrator() } + // Drag-and-drop state + 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 + 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 + } + } + } + Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { Card( - modifier = Modifier.width(600.dp).padding(16.dp), + modifier = + Modifier + .width(600.dp) + .padding(16.dp) + .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) + .then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + } else { + Modifier + }, + ), ) { Column(modifier = Modifier.padding(24.dp)) { Text( @@ -180,8 +246,8 @@ fun ComposeNoteDialog( errorMessage = null try { - // Upload attached files first - val uploadedUrls = mutableListOf() + // Upload attached files and collect results + val uploadResults = mutableListOf() for (file in attachedFiles) { uploadTracker.startUpload(file.name) val result = @@ -192,24 +258,30 @@ fun ComposeNoteDialog( signer = account.signer, ) uploadTracker.onSuccess(result) - result.blossom.url?.let { uploadedUrls.add(it) } + uploadResults.add(result) } // Append uploaded URLs to content val finalContent = buildString { append(content) - for (url in uploadedUrls) { - if (isNotBlank()) append("\n") - append(url) + for (result in uploadResults) { + result.blossom.url?.let { url -> + if (isNotBlank()) append("\n") + append(url) + } } } + // Build imeta tags from upload metadata + val imetaTags = buildIMetaTags(uploadResults) + publishNote( content = finalContent, account = account, relayManager = relayManager, replyTo = replyTo, + imetaTags = imetaTags, ) onDismiss() } catch (e: Exception) { @@ -230,19 +302,50 @@ fun ComposeNoteDialog( } } +private fun buildIMetaTags(results: List): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + val props = mutableMapOf>() + props["m"] = listOf(meta.mimeType) + props["x"] = listOf(meta.sha256) + props["size"] = listOf(meta.size.toString()) + if (meta.width != null && meta.height != null) { + props["dim"] = listOf("${meta.width}x${meta.height}") + } + meta.blurhash?.let { props["blurhash"] = listOf(it) } + IMetaTag(url = url, properties = props) + } + private suspend fun publishNote( content: String, account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, + replyTo: Event?, + imetaTags: List = emptyList(), ) { withContext(Dispatchers.IO) { if (account.isReadOnly) { throw IllegalStateException("Cannot post in read-only mode") } - val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) + val template = + TextNoteEvent.build(content) { + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + hashtags(findHashtags(content)) + references(findURLs(content)) + for (imeta in imetaTags) { + add(imeta.toTagArray()) + } + } + val signedEvent = account.signer.sign(template) relayManager.broadcastToAll(signedEvent) } }