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 dcf903fa7..8cbb09f8f 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 @@ -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(null) } val scope = rememberCoroutineScope() + val attachedFiles = remember { mutableStateListOf() } + 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() + 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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt new file mode 100644 index 000000000..2d398d54e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt @@ -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 { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + return try { + when { + clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor) -> { + @Suppress("UNCHECKED_CAST") + (clipboard.getData(DataFlavor.javaFileListFlavor) as? List) ?: 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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt new file mode 100644 index 000000000..428a9983a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt @@ -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 { + 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() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt new file mode 100644 index 000000000..a8713d047 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt @@ -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, + 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, + ) + } +}