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 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-16 12:55:30 +02:00
parent adecc35721
commit cd7ee69f50
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -42,29 +45,50 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.window.Dialog 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.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker 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.ClipboardPasteHandler
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.awt.datatransfer.DataFlavor
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTargetDropEvent
import java.io.File 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 @Composable
fun ComposeNoteDialog( fun ComposeNoteDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null, replyTo: Event? = null,
) { ) {
var content by remember { mutableStateOf("") } var content by remember { mutableStateOf("") }
var isPosting by remember { mutableStateOf(false) } var isPosting by remember { mutableStateOf(false) }
@@ -75,9 +99,51 @@ fun ComposeNoteDialog(
val uploadState by uploadTracker.state.collectAsState() val uploadState by uploadTracker.state.collectAsState()
val orchestrator = remember { DesktopUploadOrchestrator() } 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<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
}
}
}
Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) {
Card( 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)) { Column(modifier = Modifier.padding(24.dp)) {
Text( Text(
@@ -180,8 +246,8 @@ fun ComposeNoteDialog(
errorMessage = null errorMessage = null
try { try {
// Upload attached files first // Upload attached files and collect results
val uploadedUrls = mutableListOf<String>() val uploadResults = mutableListOf<UploadResult>()
for (file in attachedFiles) { for (file in attachedFiles) {
uploadTracker.startUpload(file.name) uploadTracker.startUpload(file.name)
val result = val result =
@@ -192,24 +258,30 @@ fun ComposeNoteDialog(
signer = account.signer, signer = account.signer,
) )
uploadTracker.onSuccess(result) uploadTracker.onSuccess(result)
result.blossom.url?.let { uploadedUrls.add(it) } uploadResults.add(result)
} }
// Append uploaded URLs to content // Append uploaded URLs to content
val finalContent = val finalContent =
buildString { buildString {
append(content) append(content)
for (url in uploadedUrls) { for (result in uploadResults) {
if (isNotBlank()) append("\n") result.blossom.url?.let { url ->
append(url) if (isNotBlank()) append("\n")
append(url)
}
} }
} }
// Build imeta tags from upload metadata
val imetaTags = buildIMetaTags(uploadResults)
publishNote( publishNote(
content = finalContent, content = finalContent,
account = account, account = account,
relayManager = relayManager, relayManager = relayManager,
replyTo = replyTo, replyTo = replyTo,
imetaTags = imetaTags,
) )
onDismiss() onDismiss()
} catch (e: Exception) { } catch (e: Exception) {
@@ -230,19 +302,50 @@ fun ComposeNoteDialog(
} }
} }
private fun buildIMetaTags(results: List<UploadResult>): List<IMetaTag> =
results.mapNotNull { result ->
val url = result.blossom.url ?: return@mapNotNull null
val meta = result.metadata
val props = mutableMapOf<String, List<String>>()
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( private suspend fun publishNote(
content: String, content: String,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, replyTo: Event?,
imetaTags: List<IMetaTag> = emptyList(),
) { ) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (account.isReadOnly) { if (account.isReadOnly) {
throw IllegalStateException("Cannot post in read-only mode") 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) relayManager.broadcastToAll(signedEvent)
} }
} }