feat(media): desktop upload UX with file picker, clipboard paste, and attachment row

Phase 3: Native file dialog, AWT clipboard paste handler, media
attachment UI with thumbnails, and ComposeNoteDialog media integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-16 10:46:54 +02:00
parent 443f7c6a09
commit 2181407647
4 changed files with 323 additions and 8 deletions
@@ -35,7 +35,9 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
@@ -46,9 +48,17 @@ import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction
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.ui.media.ClipboardPasteHandler
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net"
@Composable
fun ComposeNoteDialog(
@@ -61,6 +71,10 @@ fun ComposeNoteDialog(
var isPosting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val attachedFiles = remember { mutableStateListOf<File>() }
val uploadTracker = remember { DesktopUploadTracker() }
val uploadState by uploadTracker.state.collectAsState()
val orchestrator = remember { DesktopUploadOrchestrator() }
Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) {
Card(
@@ -99,6 +113,22 @@ fun ComposeNoteDialog(
Spacer(Modifier.height(8.dp))
MediaAttachmentRow(
attachedFiles = attachedFiles,
isUploading = uploadState.isUploading,
onAttach = {
val files = DesktopFilePicker.pickMediaFiles()
attachedFiles.addAll(files)
},
onPaste = {
val files = ClipboardPasteHandler.getClipboardFiles()
attachedFiles.addAll(files)
},
onRemove = { attachedFiles.remove(it) },
)
Spacer(Modifier.height(4.dp))
// Character count
Text(
"${content.length} characters",
@@ -115,6 +145,15 @@ fun ComposeNoteDialog(
)
}
uploadState.error?.let { error ->
Spacer(Modifier.height(4.dp))
Text(
"Upload error: $error",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(16.dp))
Row(
@@ -132,7 +171,7 @@ fun ComposeNoteDialog(
Button(
onClick = {
if (content.isBlank()) {
if (content.isBlank() && attachedFiles.isEmpty()) {
errorMessage = "Note cannot be empty"
return@Button
}
@@ -142,21 +181,47 @@ fun ComposeNoteDialog(
errorMessage = null
try {
// Upload attached files first
val uploadedUrls = mutableListOf<String>()
for (file in attachedFiles) {
uploadTracker.startUpload(file.name)
val result =
orchestrator.upload(
file = file,
alt = null,
serverBaseUrl = DEFAULT_BLOSSOM_SERVER,
signer = account.signer,
)
uploadTracker.onSuccess(result)
result.blossom.url?.let { uploadedUrls.add(it) }
}
// Append uploaded URLs to content
val finalContent =
buildString {
append(content)
for (url in uploadedUrls) {
if (isNotBlank()) append("\n")
append(url)
}
}
publishNote(
content = content,
content = finalContent,
account = account,
relayManager = relayManager,
replyTo = replyTo,
)
onDismiss()
} catch (e: Exception) {
errorMessage = "Failed to publish: ${e.message}"
errorMessage = "Failed: ${e.message}"
uploadTracker.onError(e.message ?: "Unknown error")
} finally {
isPosting = false
}
}
},
enabled = !isPosting && content.isNotBlank(),
enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()),
) {
Text(if (isPosting) "Publishing..." else "Publish")
}
@@ -166,10 +231,6 @@ fun ComposeNoteDialog(
}
}
/**
* Publishes a text note to relays.
* Uses the Account's key to sign the event.
*/
private suspend fun publishNote(
content: String,
account: AccountState.LoggedIn,
@@ -0,0 +1,64 @@
/*
* 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.desktop.ui.media
import java.awt.Image
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
object ClipboardPasteHandler {
fun getClipboardFiles(): List<File> {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
return try {
when {
clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor) -> {
@Suppress("UNCHECKED_CAST")
(clipboard.getData(DataFlavor.javaFileListFlavor) as? List<File>) ?: emptyList()
}
clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor) -> {
val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return emptyList()
val buffered = toBufferedImage(image)
val tempFile = File.createTempFile("clipboard_", ".png")
tempFile.deleteOnExit()
ImageIO.write(buffered, "png", tempFile)
listOf(tempFile)
}
else -> {
emptyList()
}
}
} catch (_: Exception) {
emptyList()
}
}
private fun toBufferedImage(image: Image): BufferedImage {
if (image is BufferedImage) return image
val buffered = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB)
buffered.graphics.drawImage(image, 0, 0, null)
return buffered
}
}
@@ -0,0 +1,60 @@
/*
* 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.desktop.ui.media
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.io.FilenameFilter
object DesktopFilePicker {
private val mediaExtensions =
setOf(
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"avif",
"mp4",
"webm",
"mov",
"mp3",
"ogg",
"wav",
"flac",
)
fun pickMediaFiles(parent: Frame? = null): List<File> {
val dialog =
FileDialog(parent, "Select Media", FileDialog.LOAD).apply {
isMultipleMode = true
filenameFilter =
FilenameFilter { _, name ->
val ext = name.substringAfterLast('.', "").lowercase()
ext in mediaExtensions
}
}
dialog.isVisible = true
return dialog.files?.toList() ?: emptyList()
}
}
@@ -0,0 +1,130 @@
/*
* 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.desktop.ui.media
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import java.io.File
@Composable
fun MediaAttachmentRow(
attachedFiles: List<File>,
isUploading: Boolean,
onAttach: () -> Unit,
onPaste: () -> Unit,
onRemove: (File) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onAttach) {
Icon(
Icons.Default.AttachFile,
contentDescription = "Attach media",
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onPaste) {
Icon(
Icons.Default.ContentPaste,
contentDescription = "Paste from clipboard",
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (isUploading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp))
Spacer(Modifier.height(4.dp))
}
if (attachedFiles.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (file in attachedFiles) {
AttachedFileThumbnail(file = file, onRemove = { onRemove(file) })
}
}
}
}
}
@Composable
private fun AttachedFileThumbnail(
file: File,
onRemove: () -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row {
AsyncImage(
model = file,
contentDescription = file.name,
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
)
IconButton(onClick = onRemove, modifier = Modifier.size(20.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(Modifier.width(4.dp))
Text(
text = file.name.take(12),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
}