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:
+69
-8
@@ -35,7 +35,9 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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
|
||||||
@@ -46,9 +48,17 @@ import androidx.compose.ui.window.Dialog
|
|||||||
import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction
|
import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction
|
||||||
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.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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeNoteDialog(
|
fun ComposeNoteDialog(
|
||||||
@@ -61,6 +71,10 @@ fun ComposeNoteDialog(
|
|||||||
var isPosting by remember { mutableStateOf(false) }
|
var isPosting by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
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() }) {
|
Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) {
|
||||||
Card(
|
Card(
|
||||||
@@ -99,6 +113,22 @@ fun ComposeNoteDialog(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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
|
// Character count
|
||||||
Text(
|
Text(
|
||||||
"${content.length} characters",
|
"${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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -132,7 +171,7 @@ fun ComposeNoteDialog(
|
|||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (content.isBlank()) {
|
if (content.isBlank() && attachedFiles.isEmpty()) {
|
||||||
errorMessage = "Note cannot be empty"
|
errorMessage = "Note cannot be empty"
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
@@ -142,21 +181,47 @@ fun ComposeNoteDialog(
|
|||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
|
||||||
try {
|
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(
|
publishNote(
|
||||||
content = content,
|
content = finalContent,
|
||||||
account = account,
|
account = account,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
replyTo = replyTo,
|
replyTo = replyTo,
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
errorMessage = "Failed to publish: ${e.message}"
|
errorMessage = "Failed: ${e.message}"
|
||||||
|
uploadTracker.onError(e.message ?: "Unknown error")
|
||||||
} finally {
|
} finally {
|
||||||
isPosting = false
|
isPosting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isPosting && content.isNotBlank(),
|
enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()),
|
||||||
) {
|
) {
|
||||||
Text(if (isPosting) "Publishing..." else "Publish")
|
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(
|
private suspend fun publishNote(
|
||||||
content: String,
|
content: String,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
|
|||||||
+64
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user