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:
+114
-11
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user