Merge pull request #1873 from nrobi144/feat/desktop-media

feat(desktop): Full media parity — images, video, audio, encrypted DMs, upload, lightbox
This commit is contained in:
Vitor Pamplona
2026-03-19 08:44:21 -04:00
committed by GitHub
73 changed files with 11766 additions and 1027 deletions
+5
View File
@@ -153,3 +153,8 @@ TASKS.md
# Claude Code local settings
.claude/settings.local.json
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
@@ -1609,7 +1609,7 @@ class Account(
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
}
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
@@ -108,6 +109,9 @@ interface IAccount {
/** Send a NIP-17 gift-wrapped direct message */
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>)
/** Send a NIP-17 gift-wrapped encrypted file header */
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
/** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */
suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>)
}
+24
View File
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
id("ir.mahozad.vlc-setup") version "0.1.0"
}
sourceSets {
@@ -46,8 +47,20 @@ dependencies {
// JSON
implementation(libs.jackson.module.kotlin)
// Image loading (Coil3 — explicit because commons uses implementation, not api)
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
implementation(libs.coil.svg)
// Video playback
implementation(libs.vlcj)
// EXIF stripping (lossless)
implementation(libs.commons.imaging)
// Collections
implementation(libs.kotlinx.collections.immutable)
implementation(libs.androidx.collection)
// SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps
implementation("org.slf4j:slf4j-nop:2.0.16")
@@ -65,8 +78,10 @@ dependencies {
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
nativeDistributions {
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst"
@@ -91,3 +106,12 @@ compose.desktop {
}
}
}
vlcSetup {
vlcVersion.set("3.0.21")
shouldCompressVlcFiles.set(true)
shouldIncludeAllVlcFiles.set(true)
pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc"))
pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc"))
pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc"))
}
@@ -68,4 +68,17 @@ object DesktopPreferences {
set(value) {
prefs.put(KEY_LAYOUT_MODE, value)
}
private const val KEY_BLOSSOM_SERVERS = "blossom_servers"
private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net"
var blossomServers: List<String>
get() {
val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER)
return if (raw.isBlank()) emptyList() else raw.split(",")
}
set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(","))
val preferredBlossomServer: String
get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER
}
@@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
@@ -48,6 +50,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -74,6 +77,8 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
@@ -87,8 +92,12 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope
@@ -137,7 +146,17 @@ sealed class DesktopScreen {
data object Settings : DesktopScreen()
}
fun main() =
fun main() {
DesktopImageLoaderSetup.setup()
Runtime.getRuntime().addShutdownHook(
Thread {
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.shutdown()
VlcjPlayerPool.shutdown()
},
)
// Pre-init VLC on background thread so first play is fast
Thread { VlcjPlayerPool.init() }.start()
application {
val windowState =
rememberWindowState(
@@ -363,27 +382,35 @@ fun main() =
}
}
App(
layoutMode = layoutMode,
deckState = deckState,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAddColumnDialog = showAddColumnDialog,
onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
onDismissAddColumnDialog = { showAddColumnDialog = false },
onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote,
)
val immersiveFullscreenState = remember { mutableStateOf(false) }
CompositionLocalProvider(
LocalWindowState provides windowState,
LocalAwtWindow provides window,
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
) {
App(
layoutMode = layoutMode,
deckState = deckState,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAddColumnDialog = showAddColumnDialog,
onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
onDismissAddColumnDialog = { showAddColumnDialog = false },
onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote,
)
}
}
}
}
@Composable
fun App(
@@ -622,22 +649,31 @@ fun MainContent(
)
}
is com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -> {
val innerNote = localCache.getOrCreateNote(innerEvent.id)
val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey)
if (innerNote.event == null) {
innerNote.loadEvent(innerEvent, innerAuthor, emptyList())
}
iAccount.chatroomList.addMessage(
innerEvent.chatroomKey(iAccount.pubKey),
innerNote,
)
}
is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> {
val reactionNote = localCache.getOrCreateNote(innerEvent.id)
val reactionAuthor = localCache.getOrCreateUser(innerEvent.pubKey)
if (reactionNote.event == null) {
reactionNote.loadEvent(innerEvent, reactionAuthor, emptyList())
}
// Attach reaction to the target message note
innerEvent.originalPost().forEach { targetId ->
val targetNote = localCache.getNoteIfExists(targetId)
targetNote?.addReaction(reactionNote)
}
}
else -> {
println("Unhandled NIP-17 inner event: ${innerEvent.kind}")
}
else -> {}
}
}
}
@@ -665,60 +701,76 @@ fun MainContent(
}
}
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) {
when (layoutMode) {
LayoutMode.SINGLE_PANE -> {
SinglePaneLayout(
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.weight(1f),
)
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize().weight(1f)) {
when (layoutMode) {
LayoutMode.SINGLE_PANE -> {
SinglePaneLayout(
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.weight(1f),
)
}
LayoutMode.DECK -> {
if (!isImmersive) {
DeckSidebar(
onAddColumn = onShowAddColumnDialog,
onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
)
VerticalDivider()
}
DeckLayout(
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
modifier = Modifier.weight(1f),
)
}
}
} // end Row
LayoutMode.DECK -> {
DeckSidebar(
onAddColumn = onShowAddColumnDialog,
onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
)
// Persistent media control bar
com.vitorpamplona.amethyst.desktop.ui.media
.NowPlayingBar()
} // end Column
VerticalDivider()
DeckLayout(
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
modifier = Modifier.weight(1f),
)
}
}
}
// Global fullscreen video overlay
com.vitorpamplona.amethyst.desktop.ui.media
.GlobalFullscreenOverlay()
// Snackbar for zap feedback
SnackbarHost(
@@ -781,7 +833,9 @@ fun RelaySettingsScreen(
accountManager.loadNwcConnection()
}
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
) {
Text(
"Settings",
style = MaterialTheme.typography.headlineMedium,
@@ -872,6 +926,15 @@ fun RelaySettingsScreen(
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Media Server Settings
MediaServerSettings(
initialServers = DesktopPreferences.blossomServers,
onServersChanged = { DesktopPreferences.blossomServers = it },
)
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Developer Settings Section (only in debug mode)
if (DebugConfig.isDebugMode) {
com.vitorpamplona.amethyst.desktop.ui
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
@@ -156,6 +157,37 @@ class DesktopIAccount(
scope.launch { dmSendTracker.sendBatch(batch) }
}
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
// Optimistic local add
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
// Collect wraps with target relays and send
val batch =
result.wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
wrap to targetRelays
}
scope.launch { dmSendTracker.sendBatch(batch) }
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
val batch =
wraps.map { wrap ->
@@ -0,0 +1,95 @@
/*
* 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.service.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.Uri
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
import com.vitorpamplona.amethyst.commons.richtext.Base64Image
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import java.awt.image.BufferedImage
@Stable
class DesktopBase64Fetcher(
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
runCatching {
val platformImage = Base64Image.toPlatformImage(data.toString())
val bufferedImage = platformImage.toBufferedImage()
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}.getOrNull()
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? =
if (data.scheme == "data") {
DesktopBase64Fetcher(data)
} else {
null
}
}
object BKeyer : Keyer<Uri> {
override fun key(
data: Uri,
options: Options,
): String? =
if (data.scheme == "data") {
sha256(data.toString().toByteArray()).toHexKey()
} else {
null
}
}
}
internal fun bufferedImageToSkiaBitmap(bi: BufferedImage): Bitmap {
val w = bi.width
val h = bi.height
val pixels = IntArray(w * h)
bi.getRGB(0, 0, w, h, pixels, 0, w)
val bitmap = Bitmap()
bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
bitmap.installPixels(convertArgbToBgra(pixels))
bitmap.setImmutable()
return bitmap
}
@@ -0,0 +1,87 @@
/*
* 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.service.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
data class BlurhashWrapper(
val blurhash: String,
)
@Stable
class DesktopBlurHashFetcher(
private val data: BlurhashWrapper,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val hash = data.blurhash
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
val bufferedImage = platformImage.toBufferedImage()
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<BlurhashWrapper> {
override fun create(
data: BlurhashWrapper,
options: Options,
imageLoader: ImageLoader,
): Fetcher = DesktopBlurHashFetcher(data)
}
object BKeyer : Keyer<BlurhashWrapper> {
override fun key(
data: BlurhashWrapper,
options: Options,
): String = data.blurhash
}
}
internal fun convertArgbToBgra(pixels: IntArray): ByteArray {
val bytes = ByteArray(pixels.size * 4)
for (i in pixels.indices) {
val argb = pixels[i]
val a = (argb shr 24) and 0xFF
val r = (argb shr 16) and 0xFF
val g = (argb shr 8) and 0xFF
val b = argb and 0xFF
val offset = i * 4
bytes[offset] = b.toByte()
bytes[offset + 1] = g.toByte()
bytes[offset + 2] = r.toByte()
bytes[offset + 3] = a.toByte()
}
return bytes
}
@@ -0,0 +1,94 @@
/*
* 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.service.images
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.size.Precision
import coil3.svg.SvgDecoder
import okio.Path.Companion.toOkioPath
import java.io.File
object DesktopImageLoaderSetup {
@OptIn(DelicateCoilApi::class)
fun setup() {
SingletonImageLoader.setUnsafe(createImageLoader())
}
fun createImageLoader(): ImageLoader =
ImageLoader
.Builder(PlatformContext.INSTANCE)
.memoryCache { newMemoryCache() }
.diskCache { newDiskCache() }
.precision(Precision.INEXACT)
.components {
add(SvgDecoder.Factory())
add(SkiaGifDecoder.Factory())
add(DesktopBase64Fetcher.Factory)
add(DesktopBlurHashFetcher.Factory)
add(DesktopBase64Fetcher.BKeyer)
add(DesktopBlurHashFetcher.BKeyer)
}.build()
private fun newMemoryCache(): MemoryCache {
val maxMemory = Runtime.getRuntime().maxMemory()
val cacheSize = (maxMemory * 0.15).toLong().coerceAtMost(256L * 1024 * 1024)
return MemoryCache
.Builder()
.maxSizeBytes(cacheSize)
.strongReferencesEnabled(false)
.build()
}
private fun newDiskCache(): DiskCache =
DiskCache
.Builder()
.directory(cacheDir().resolve("AmethystDesktop/image_cache").toOkioPath())
.maxSizeBytes(512L * 1024 * 1024)
.build()
private fun cacheDir(): File {
val os = System.getProperty("os.name").lowercase()
return when {
"mac" in os -> {
File(System.getProperty("user.home"), "Library/Caches")
}
"win" in os -> {
File(
System.getenv("LOCALAPPDATA")
?: System.getProperty("user.home"),
)
}
else -> {
File(
System.getenv("XDG_CACHE_HOME")
?: "${System.getProperty("user.home")}/.cache",
)
}
}
}
}
@@ -0,0 +1,62 @@
/*
* 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.service.images
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Codec
import org.jetbrains.skia.Data
class SkiaGifDecoder(
private val source: ImageSource,
) : Decoder {
override suspend fun decode(): DecodeResult {
val bytes = source.source().use { it.readByteArray() }
val data = Data.makeFromBytes(bytes)
val codec = Codec.makeFromData(data)
val bitmap = Bitmap()
bitmap.allocN32Pixels(codec.width, codec.height)
codec.readPixels(bitmap, 0)
bitmap.setImmutable()
return DecodeResult(
image = bitmap.asImage(),
isSampled = false,
)
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader,
): Decoder? {
val mimeType = result.mimeType ?: return null
if (mimeType != "image/gif") return null
return SkiaGifDecoder(result.source)
}
}
}
@@ -0,0 +1,45 @@
/*
* 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.service.media
import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy
import java.io.File
/**
* Discovers bundled VLC libraries on Windows and Linux.
* Reads the Compose application resources directory and looks for a vlc/ subdirectory.
*/
class BundledVlcDiscoverer : NativeDiscoveryStrategy {
override fun supported(): Boolean {
val os = System.getProperty("os.name").lowercase()
return "mac" !in os
}
override fun discover(): String {
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return ""
val vlcDir = File(resourcesDir, "vlc")
return if (vlcDir.isDirectory) vlcDir.absolutePath else ""
}
override fun onFound(path: String): Boolean = true
override fun onSetPluginPath(path: String): Boolean = true
}
@@ -0,0 +1,73 @@
/*
* 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.service.media
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
/**
* Handles decryption of media files for NIP-17 DMs.
* Uses AESGCM cipher from quartz (commonMain).
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
*/
object EncryptedMediaService {
private val httpClient = OkHttpClient()
private val cache = ConcurrentHashMap<String, ByteArray>()
private const val MAX_CACHE_ENTRIES = 20
/**
* Download and decrypt an encrypted file from a URL.
* Results are cached by URL to avoid re-downloading on scroll/recomposition.
* Returns the decrypted bytes.
*/
suspend fun downloadAndDecrypt(
url: String,
keyBytes: ByteArray,
nonce: ByteArray,
): ByteArray {
cache[url]?.let { return it }
return withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
val encryptedBytes =
response.use {
if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}")
it.body.bytes()
}
val cipher = AESGCM(keyBytes, nonce)
val decrypted = cipher.decrypt(encryptedBytes)
// Evict oldest entries if cache is full
if (cache.size >= MAX_CACHE_ENTRIES) {
cache.keys.firstOrNull()?.let { cache.remove(it) }
}
cache[url] = decrypted
decrypted
}
}
}
@@ -0,0 +1,497 @@
/*
* 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.service.media
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import com.vitorpamplona.amethyst.desktop.ui.media.MediaType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import org.jetbrains.skia.Image as SkiaImage
data class MediaPlaybackState(
val url: String? = null,
val type: MediaType = MediaType.VIDEO,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val position: Float = 0f,
val duration: Long = 0L,
val currentTime: Long = 0L,
val aspectRatio: Float = 16f / 9f,
val volume: Int = 100,
val isMuted: Boolean = false,
)
object GlobalMediaPlayer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Video state
private val _videoFrame = MutableStateFlow<ImageBitmap?>(null)
val videoFrame: StateFlow<ImageBitmap?> = _videoFrame.asStateFlow()
private val _videoState = MutableStateFlow(MediaPlaybackState())
val videoState: StateFlow<MediaPlaybackState> = _videoState.asStateFlow()
// Audio state
private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO))
val audioState: StateFlow<MediaPlaybackState> = _audioState.asStateFlow()
// Fullscreen
private val _isFullscreen = MutableStateFlow(false)
val isFullscreen: StateFlow<Boolean> = _isFullscreen.asStateFlow()
// VLC players — kept alive between plays
private var videoPlayer: EmbeddedMediaPlayer? = null
private var audioPlayer: MediaPlayer? = null
// Skia bitmap for video rendering
private var skBitmap: Bitmap? = null
private var pixelBytes: ByteArray? = null
// Position polling job
private var videoPollingJob: Job? = null
private var audioPollingJob: Job? = null
fun playVideo(
url: String,
seekPosition: Float = 0f,
) {
// If already playing this URL, just seek
val current = _videoState.value
if (current.url == url && videoPlayer != null) {
if (seekPosition > 0f) {
videoPlayer?.controls()?.setPosition(seekPosition)
}
if (!current.isPlaying) {
videoPlayer?.controls()?.play()
}
return
}
// Stop current video if different URL
if (current.url != null && current.url != url) {
videoPlayer?.controls()?.stop()
}
_videoState.value =
MediaPlaybackState(
url = url,
type = MediaType.VIDEO,
isBuffering = true,
)
scope.launch(Dispatchers.IO) {
if (!VlcjPlayerPool.init()) {
_videoState.value = _videoState.value.copy(isBuffering = false)
return@launch
}
val player =
videoPlayer ?: VlcjPlayerPool.acquire() ?: run {
_videoState.value = _videoState.value.copy(isBuffering = false)
return@launch
}
// Only set up surface on first acquisition
if (videoPlayer == null) {
setupVideoSurface(player)
setupVideoEventListener(player)
videoPlayer = player
}
var didSeek = seekPosition <= 0f
// Temporary listener for initial seek
if (!didSeek) {
val seekListener =
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
if (!didSeek) {
didSeek = true
mediaPlayer.controls().setPosition(seekPosition)
mediaPlayer.events().removeMediaPlayerEventListener(this)
}
}
}
player.events().addMediaPlayerEventListener(seekListener)
}
val vol = _videoState.value.volume
player.media().play(url, ":start-volume=$vol")
startVideoPolling()
}
}
fun playAudio(url: String) {
val current = _audioState.value
if (current.url == url && audioPlayer != null) {
if (!current.isPlaying) {
audioPlayer?.controls()?.play()
}
return
}
if (current.url != null && current.url != url) {
audioPlayer?.controls()?.stop()
}
_audioState.value =
MediaPlaybackState(
url = url,
type = MediaType.AUDIO,
isBuffering = true,
)
scope.launch(Dispatchers.IO) {
val player =
audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run {
_audioState.value = _audioState.value.copy(isBuffering = false)
return@launch
}
if (audioPlayer == null) {
setupAudioEventListener(player)
audioPlayer = player
}
val vol = _audioState.value.volume
player.media().play(url, ":start-volume=$vol")
startAudioPolling()
}
}
fun toggleVideoPlayPause() {
val player = videoPlayer ?: return
val state = _videoState.value
if (state.url == null) return
if (state.isPlaying) {
player.controls().pause()
} else {
if (state.position <= 0f && !player.status().isPlaying) {
state.url.let { player.media().play(it) }
} else {
player.controls().play()
}
}
}
fun toggleAudioPlayPause() {
val player = audioPlayer ?: return
val state = _audioState.value
if (state.url == null) return
if (state.isPlaying) {
player.controls().pause()
} else {
if (state.position <= 0f && !player.status().isPlaying) {
state.url.let { player.media().play(it) }
} else {
player.controls().play()
}
}
}
fun seekVideo(position: Float) {
videoPlayer?.controls()?.setPosition(position)
}
fun seekAudio(position: Float) {
audioPlayer?.controls()?.setPosition(position)
}
fun setVideoVolume(volume: Int) {
videoPlayer?.audio()?.setVolume(volume)
_videoState.value = _videoState.value.copy(volume = volume)
}
fun setAudioVolume(volume: Int) {
audioPlayer?.audio()?.setVolume(volume)
_audioState.value = _audioState.value.copy(volume = volume)
}
fun toggleVideoMute() {
val muted = !_videoState.value.isMuted
videoPlayer?.audio()?.isMute = muted
_videoState.value = _videoState.value.copy(isMuted = muted)
}
fun toggleAudioMute() {
val muted = !_audioState.value.isMuted
audioPlayer?.audio()?.isMute = muted
_audioState.value = _audioState.value.copy(isMuted = muted)
}
fun stopVideo() {
videoPollingJob?.cancel()
videoPollingJob = null
videoPlayer?.controls()?.stop()
_videoState.value = MediaPlaybackState()
_videoFrame.value = null
_isFullscreen.value = false
}
fun stopAudio() {
audioPollingJob?.cancel()
audioPollingJob = null
audioPlayer?.controls()?.stop()
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
}
fun toggleFullscreen() {
_isFullscreen.value = !_isFullscreen.value
}
fun exitFullscreen() {
_isFullscreen.value = false
}
fun shutdown() {
videoPollingJob?.cancel()
audioPollingJob?.cancel()
videoPlayer?.let { p ->
try {
p.controls().stop()
} catch (_: Exception) {
}
VlcjPlayerPool.release(p)
}
videoPlayer = null
audioPlayer?.let { p ->
try {
p.controls().stop()
} catch (_: Exception) {
}
VlcjPlayerPool.releaseAudioPlayer(p)
}
audioPlayer = null
_videoState.value = MediaPlaybackState()
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
_videoFrame.value = null
_isFullscreen.value = false
scope.cancel()
}
private fun setupVideoSurface(player: EmbeddedMediaPlayer) {
val bufferFormatCallback =
object : BufferFormatCallback {
override fun getBufferFormat(
sourceWidth: Int,
sourceHeight: Int,
): BufferFormat {
if (sourceHeight > 0) {
_videoState.value =
_videoState.value.copy(
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(),
)
}
val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
skBitmap = bmp
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
return RV32BufferFormat(sourceWidth, sourceHeight)
}
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
}
val renderCallback =
RenderCallback { _, nativeBuffers, _ ->
val bmp = skBitmap ?: return@RenderCallback
val bytes = pixelBytes ?: return@RenderCallback
val buffer = nativeBuffers[0]
buffer.rewind()
buffer.get(bytes)
bmp.installPixels(bytes)
_videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
}
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
player.videoSurface().set(surface)
}
private fun setupVideoEventListener(player: EmbeddedMediaPlayer) {
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
val state = _videoState.value
_videoState.value =
state.copy(
isPlaying = true,
isBuffering = false,
duration = mediaPlayer.status().length(),
)
}
override fun paused(mediaPlayer: MediaPlayer) {
_videoState.value = _videoState.value.copy(isPlaying = false)
}
override fun stopped(mediaPlayer: MediaPlayer) {
_videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false)
}
override fun buffering(
mediaPlayer: MediaPlayer,
newCache: Float,
) {
_videoState.value = _videoState.value.copy(isBuffering = newCache < 100f)
}
override fun positionChanged(
mediaPlayer: MediaPlayer,
newPosition: Float,
) {
_videoState.value =
_videoState.value.copy(
position = newPosition,
currentTime = (newPosition * _videoState.value.duration).toLong(),
)
}
override fun finished(mediaPlayer: MediaPlayer) {
_videoState.value =
_videoState.value.copy(
isPlaying = false,
isBuffering = false,
position = 0f,
currentTime = 0L,
)
}
override fun error(mediaPlayer: MediaPlayer) {
_videoState.value = _videoState.value.copy(isBuffering = false)
println("VLC: playback error for ${_videoState.value.url}")
}
},
)
}
private fun setupAudioEventListener(player: MediaPlayer) {
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
_audioState.value =
_audioState.value.copy(
isPlaying = true,
isBuffering = false,
duration = mediaPlayer.status().length(),
)
}
override fun paused(mediaPlayer: MediaPlayer) {
_audioState.value = _audioState.value.copy(isPlaying = false)
}
override fun stopped(mediaPlayer: MediaPlayer) {
_audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false)
}
override fun positionChanged(
mediaPlayer: MediaPlayer,
newPosition: Float,
) {
_audioState.value =
_audioState.value.copy(
position = newPosition,
currentTime = (newPosition * _audioState.value.duration).toLong(),
)
}
override fun finished(mediaPlayer: MediaPlayer) {
_audioState.value =
_audioState.value.copy(
isPlaying = false,
position = 0f,
currentTime = 0L,
)
}
},
)
}
private fun startVideoPolling() {
videoPollingJob?.cancel()
videoPollingJob =
scope.launch {
while (true) {
delay(500)
val player = videoPlayer ?: break
val state = _videoState.value
if (state.isPlaying) {
try {
_videoState.value =
state.copy(
position = player.status().position(),
currentTime = player.status().time(),
)
} catch (_: Exception) {
}
}
}
}
}
private fun startAudioPolling() {
audioPollingJob?.cancel()
audioPollingJob =
scope.launch {
while (true) {
delay(500)
val player = audioPlayer ?: break
val state = _audioState.value
if (state.isPlaying) {
try {
_audioState.value =
state.copy(
position = player.status().position(),
currentTime = player.status().time(),
)
} catch (_: Exception) {
}
}
}
}
}
}
@@ -0,0 +1,56 @@
/*
* 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.service.media
import com.sun.jna.NativeLibrary
import uk.co.caprica.vlcj.binding.lib.LibC
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
import java.io.File
/**
* Discovers bundled VLC libraries on macOS.
* Must force-load libvlccore before libvlc to avoid link errors.
*/
class MacOsVlcDiscoverer :
BaseNativeDiscoveryStrategy(
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
arrayOf("%s/plugins"),
) {
override fun supported(): Boolean {
val os = System.getProperty("os.name").lowercase()
return "mac" in os
}
override fun discoveryDirectories(): List<String> {
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return emptyList()
val vlcDir = File(resourcesDir, "vlc")
return if (vlcDir.isDirectory) listOf(vlcDir.absolutePath) else emptyList()
}
override fun onFound(path: String): Boolean {
NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path)
NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName())
return true
}
override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
}
@@ -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.service.media
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
object ServerHealthCheck {
private val httpClient =
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
enum class ServerStatus {
ONLINE,
OFFLINE,
UNKNOWN,
}
/**
* Check if a Blossom server is reachable via HEAD request.
*/
suspend fun check(serverUrl: String): ServerStatus =
withContext(Dispatchers.IO) {
try {
val url = serverUrl.removeSuffix("/")
val request =
Request
.Builder()
.url(url)
.head()
.build()
val response = httpClient.newCall(request).execute()
response.use {
if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE
}
} catch (_: Exception) {
ServerStatus.OFFLINE
}
}
}
@@ -0,0 +1,139 @@
/*
* 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.service.media
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.jetbrains.skia.Image as SkiaImage
object VideoThumbnailCache {
private val cache = ConcurrentHashMap<String, ImageBitmap>()
private val pending = ConcurrentHashMap<String, Boolean>()
fun getCached(url: String): ImageBitmap? = cache[url]
suspend fun getThumbnail(url: String): ImageBitmap? {
cache[url]?.let { return it }
if (pending.putIfAbsent(url, true) != null) return null
return withContext(Dispatchers.IO) {
try {
extractFirstFrame(url)?.also { cache[url] = it }
} finally {
pending.remove(url)
}
}
}
private fun extractFirstFrame(url: String): ImageBitmap? {
if (!VlcjPlayerPool.init()) {
println("VLC thumbnail: init failed for $url")
return null
}
val player = VlcjPlayerPool.acquireForThumbnail()
if (player == null) {
println("VLC thumbnail: pool exhausted for $url")
return null
}
var result: ImageBitmap? = null
val latch = CountDownLatch(1)
val bufferFormatCallback =
object : BufferFormatCallback {
override fun getBufferFormat(
sourceWidth: Int,
sourceHeight: Int,
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight)
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
}
val renderCallback =
RenderCallback { _, nativeBuffers, bufferFormat ->
if (result != null) return@RenderCallback
try {
if (nativeBuffers.isEmpty()) return@RenderCallback
val w = bufferFormat.width
val h = bufferFormat.height
if (w <= 0 || h <= 0) return@RenderCallback
val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
val bytes = ByteArray(w * h * 4)
val buffer = nativeBuffers[0]
buffer.rewind()
buffer.get(bytes)
bmp.installPixels(bytes)
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
latch.countDown()
} catch (e: Exception) {
println("VLC thumbnail: render error for $url${e.message}")
latch.countDown()
}
}
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
if (surface == null) {
println("VLC thumbnail: surface creation failed for $url")
VlcjPlayerPool.release(player)
return null
}
player.videoSurface().set(surface)
player.audio().setVolume(0)
player.audio().isMute = true
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun error(mediaPlayer: MediaPlayer) {
println("VLC thumbnail: playback error for $url")
latch.countDown()
}
},
)
player.media().play(url)
// Wait up to 8 seconds for first frame (network videos can be slow)
latch.await(8, TimeUnit.SECONDS)
if (result == null) {
println("VLC thumbnail: timed out or failed for $url")
}
VlcjPlayerPool.release(player)
return result
}
}
@@ -0,0 +1,287 @@
/*
* 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.service.media
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Manages a pool of VLCJ media players to avoid costly create/destroy cycles.
* Keeps strong references to prevent GC crashes from native callbacks.
*
* IMPORTANT: Never let player instances be garbage collected while native
* callbacks are active this causes JVM segfaults.
*/
object VlcjPlayerPool {
private val available = AtomicBoolean(false)
private val initAttempted = AtomicBoolean(false)
private val initLatch = CountDownLatch(1)
private var factory: MediaPlayerFactory? = null
// Video player pool (for actual playback)
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_POOL_SIZE = 1
// Thumbnail player pool (separate so thumbnails don't compete with playback)
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_THUMB_POOL_SIZE = 2
// Audio player pool (shared factory with --no-video)
private var audioFactory: MediaPlayerFactory? = null
private val allAudioPlayers = mutableListOf<MediaPlayer>()
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
private const val MAX_AUDIO_POOL_SIZE = 1
/**
* Initialize the pool. Thread-safe only runs once.
* Returns false if VLC is not installed.
*/
fun init(): Boolean {
if (available.get()) return true
// Only one thread performs init; others wait
if (!initAttempted.compareAndSet(false, true)) {
initLatch.await(10, TimeUnit.SECONDS)
return available.get()
}
return try {
// Try bundled VLC first, then fall through to system VLC
val discovery =
try {
val nd =
NativeDiscovery(
BundledVlcDiscoverer(),
MacOsVlcDiscoverer(),
)
val found = nd.discover()
if (found) {
println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}")
} else {
println("VLC: bundled discovery failed, falling back to system VLC")
}
found
} catch (e: Throwable) {
println("VLC: bundled discovery threw ${e.message}")
false
}
if (!discovery) {
// Try default system discovery
val systemDiscovery = NativeDiscovery().discover()
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
}
val f = MediaPlayerFactory("--no-xlib")
factory = f
available.set(true)
println("VLC: MediaPlayerFactory created successfully")
true
} catch (e: Throwable) {
println("VLC: init failed — ${e.message}")
available.set(false)
false
} finally {
initLatch.countDown()
}
}
fun isAvailable(): Boolean = available.get()
/**
* Create a callback video surface using the factory's API.
*/
fun createVideoSurface(
bufferFormatCallback: BufferFormatCallback,
renderCallback: RenderCallback,
): VideoSurface? {
val f = factory ?: return null
return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true)
}
/**
* Acquire a video player from the pool or create a new one.
* Returns null if VLC is not available or pool is at capacity.
*/
fun acquire(): EmbeddedMediaPlayer? {
if (!available.get()) return null
val f = factory ?: return null
synchronized(allPlayers) {
idlePlayers.poll()?.let { return it }
if (allPlayers.size >= MAX_POOL_SIZE) return null
return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
allPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Acquire a player dedicated to thumbnail extraction.
* Separate pool so thumbnails don't compete with playback.
*/
fun acquireForThumbnail(): EmbeddedMediaPlayer? {
if (!available.get()) return null
val f = factory ?: return null
synchronized(allThumbPlayers) {
idleThumbPlayers.poll()?.let { return it }
if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) {
// Fall back to main pool if thumb pool is full
return acquire()
}
return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
allThumbPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Acquire an audio-only player from the pool.
* Uses a separate factory with --no-video for efficiency.
*/
fun acquireAudioPlayer(): MediaPlayer? {
if (!init()) return null
synchronized(allAudioPlayers) {
idleAudioPlayers.poll()?.let { return it }
if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null
val af =
audioFactory ?: try {
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
} catch (_: Throwable) {
return null
}
return try {
val player = af.mediaPlayers().newMediaPlayer()
allAudioPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Return a video player to the pool for reuse.
*/
fun release(player: EmbeddedMediaPlayer) {
try {
player.controls().stop()
// Return to correct pool
synchronized(allThumbPlayers) {
if (player in allThumbPlayers) {
idleThumbPlayers.offer(player)
return
}
}
idlePlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
}
}
/**
* Return an audio player to the pool for reuse.
*/
fun releaseAudioPlayer(player: MediaPlayer) {
try {
player.controls().stop()
idleAudioPlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
}
}
/**
* Shut down the entire pool. Call on app exit.
*/
fun shutdown() {
synchronized(allPlayers) {
idlePlayers.clear()
for (player in allPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allPlayers.clear()
}
synchronized(allThumbPlayers) {
idleThumbPlayers.clear()
for (player in allThumbPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allThumbPlayers.clear()
}
synchronized(allAudioPlayers) {
idleAudioPlayers.clear()
for (player in allAudioPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allAudioPlayers.clear()
}
try {
factory?.release()
} catch (_: Exception) {
// Ignore
}
try {
audioFactory?.release()
} catch (_: Exception) {
// Ignore
}
factory = null
audioFactory = null
available.set(false)
}
}
@@ -0,0 +1,43 @@
/*
* 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.service.upload
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import java.util.Base64
object DesktopBlossomAuth {
suspend fun createUploadAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
): String {
val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
return encodeAuthHeader(event)
}
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
return "Nostr $b64"
}
}
@@ -0,0 +1,106 @@
/*
* 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.service.upload
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
class DesktopBlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val requestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers["X-Reason"] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
}
}
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers["X-Reason"] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
}
}
}
@@ -0,0 +1,50 @@
/*
* 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.service.upload
import org.apache.commons.imaging.Imaging
import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter
import java.io.ByteArrayOutputStream
import java.io.File
object DesktopMediaCompressor {
fun stripExif(file: File): File {
if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) {
return file
}
return try {
val bytes = file.readBytes()
// Check if it has EXIF data
val metadata = Imaging.getMetadata(bytes)
if (metadata == null) return file
val baos = ByteArrayOutputStream()
ExifRewriter().removeExifMetadata(bytes, baos)
val stripped = File.createTempFile("stripped_", ".jpg")
stripped.writeBytes(baos.toByteArray())
stripped.deleteOnExit()
stripped
} catch (_: Exception) {
file
}
}
}
@@ -0,0 +1,89 @@
/*
* 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.service.upload
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
import javax.imageio.ImageIO
data class MediaMetadata(
val sha256: String,
val size: Long,
val mimeType: String,
val width: Int? = null,
val height: Int? = null,
val blurhash: String? = null,
)
object DesktopMediaMetadata {
fun compute(file: File): MediaMetadata {
val bytes = file.readBytes()
val hash = sha256(bytes).toHexKey()
val mimeType = guessMimeType(file)
var width: Int? = null
var height: Int? = null
var blurhash: String? = null
if (mimeType.startsWith("image/")) {
try {
val image = ImageIO.read(file)
if (image != null) {
width = image.width
height = image.height
blurhash = image.toPlatformImage().toBlurhash()
}
} catch (_: Exception) {
}
}
return MediaMetadata(
sha256 = hash,
size = bytes.size.toLong(),
mimeType = mimeType,
width = width,
height = height,
blurhash = blurhash,
)
}
fun guessMimeType(file: File): String {
val ext = file.extension.lowercase()
return when (ext) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
"svg" -> "image/svg+xml"
"avif" -> "image/avif"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"mov" -> "video/quicktime"
"mp3" -> "audio/mpeg"
"ogg" -> "audio/ogg"
"wav" -> "audio/wav"
"flac" -> "audio/flac"
else -> "application/octet-stream"
}
}
}
@@ -0,0 +1,136 @@
/*
* 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.service.upload
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
data class UploadResult(
val blossom: BlossomUploadResult,
val metadata: MediaMetadata,
)
data class EncryptedUploadResult(
val blossom: BlossomUploadResult,
val metadata: MediaMetadata,
val encryptedHash: String,
val encryptedSize: Int,
)
class DesktopUploadOrchestrator(
private val client: DesktopBlossomClient = DesktopBlossomClient(),
) {
suspend fun upload(
file: File,
alt: String?,
serverBaseUrl: String,
signer: NostrSigner,
stripExif: Boolean = true,
): UploadResult {
// 1. Strip EXIF if requested (JPEG only)
val processedFile =
if (stripExif) {
DesktopMediaCompressor.stripExif(file)
} else {
file
}
// 2. Compute metadata (hash, dimensions, blurhash)
val metadata = DesktopMediaMetadata.compute(processedFile)
// 3. Create auth header
val authHeader =
DesktopBlossomAuth.createUploadAuth(
hash = metadata.sha256,
size = metadata.size,
alt = alt ?: "Uploading ${file.name}",
signer = signer,
)
// 4. Upload
val result =
client.upload(
file = processedFile,
contentType = metadata.mimeType,
serverBaseUrl = serverBaseUrl,
authHeader = authHeader,
)
// 5. Clean up temp file if we stripped EXIF
if (processedFile != file) {
processedFile.delete()
}
return UploadResult(blossom = result, metadata = metadata)
}
/**
* Upload a file encrypted with AES-GCM for NIP-17 DM file sharing.
* Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes,
* then uploads the encrypted blob to Blossom.
*/
suspend fun uploadEncrypted(
file: File,
cipher: AESGCM,
serverBaseUrl: String,
signer: NostrSigner,
): EncryptedUploadResult {
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
val metadata = DesktopMediaMetadata.compute(file)
// 2. Read file bytes and encrypt
val plaintext = file.readBytes()
val encrypted = cipher.encrypt(plaintext)
// 3. Compute SHA256 of ENCRYPTED blob (not plaintext)
val encryptedHash = sha256(encrypted).toHexKey()
val encryptedSize = encrypted.size
// 4. Create Blossom auth with encrypted hash and size
val authHeader =
DesktopBlossomAuth.createUploadAuth(
hash = encryptedHash,
size = encryptedSize.toLong(),
alt = "Encrypted upload",
signer = signer,
)
// 5. Upload encrypted blob as opaque binary
val result =
client.upload(
bytes = encrypted,
contentType = "application/octet-stream",
serverBaseUrl = serverBaseUrl,
authHeader = authHeader,
)
return EncryptedUploadResult(
blossom = result,
metadata = metadata,
encryptedHash = encryptedHash,
encryptedSize = encryptedSize,
)
}
}
@@ -0,0 +1,52 @@
/*
* 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.service.upload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
data class UploadState(
val isUploading: Boolean = false,
val fileName: String? = null,
val error: String? = null,
val result: UploadResult? = null,
)
class DesktopUploadTracker {
private val _state = MutableStateFlow(UploadState())
val state = _state.asStateFlow()
fun startUpload(fileName: String) {
_state.value = UploadState(isUploading = true, fileName = fileName)
}
fun onSuccess(result: UploadResult) {
_state.value = UploadState(isUploading = false, result = result)
}
fun onError(error: String) {
_state.value = UploadState(isUploading = false, error = error)
}
fun reset() {
_state.value = UploadState()
}
}
@@ -289,7 +289,9 @@ fun BookmarksScreen(
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
NoteActionsRow(
event = event,
@@ -20,6 +20,8 @@
*/
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.Column
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
@@ -35,36 +38,117 @@ 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
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
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.window.Dialog
import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
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.service.upload.UploadResult
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 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.launch
import kotlinx.coroutines.withContext
import java.awt.datatransfer.DataFlavor
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTargetDropEvent
import java.io.File
private val MEDIA_EXTENSIONS =
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
private val IMAGE_EXTENSIONS =
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif")
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ComposeNoteDialog(
onDismiss: () -> Unit,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null,
replyTo: Event? = null,
) {
var content by remember { mutableStateOf("") }
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() }
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
var postAsPicture by remember { mutableStateOf(false) }
// 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() }) {
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)) {
Text(
@@ -85,20 +169,67 @@ fun ComposeNoteDialog(
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = content,
value = if (postAsPicture) "" else content,
onValueChange = {
content = it
errorMessage = null
},
modifier = Modifier.fillMaxWidth().height(200.dp),
label = { Text("What's on your mind?") },
placeholder = { Text("Write your note...") },
enabled = !isPosting,
maxLines = 10,
modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
label = {
Text(
if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
)
},
placeholder = { Text(if (postAsPicture) "" else "Write your note...") },
enabled = !isPosting && !postAsPicture,
maxLines = if (postAsPicture) 1 else 10,
)
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) },
)
// Server selector + post type — shown when files are attached
if (attachedFiles.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
ServerSelector(
selectedServer = selectedServer,
onServerSelected = { selectedServer = it },
)
// Post type toggle — only when images are attached
val hasImages =
attachedFiles.any {
it.extension.lowercase() in IMAGE_EXTENSIONS
}
if (hasImages) {
PostTypeSelector(
isPicture = postAsPicture,
onToggle = { postAsPicture = it },
)
}
}
}
Spacer(Modifier.height(4.dp))
// Character count
Text(
"${content.length} characters",
@@ -115,6 +246,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 +272,7 @@ fun ComposeNoteDialog(
Button(
onClick = {
if (content.isBlank()) {
if (content.isBlank() && attachedFiles.isEmpty()) {
errorMessage = "Note cannot be empty"
return@Button
}
@@ -142,21 +282,61 @@ fun ComposeNoteDialog(
errorMessage = null
try {
publishNote(
content = content,
account = account,
relayManager = relayManager,
replyTo = replyTo,
)
// Upload attached files and collect results
val uploadResults = mutableListOf<UploadResult>()
for (file in attachedFiles) {
uploadTracker.startUpload(file.name)
val result =
orchestrator.upload(
file = file,
alt = null,
serverBaseUrl = selectedServer,
signer = account.signer,
)
uploadTracker.onSuccess(result)
uploadResults.add(result)
}
// Append uploaded URLs to content
val finalContent =
buildString {
append(content)
for (result in uploadResults) {
result.blossom.url?.let { url ->
if (isNotBlank()) append("\n")
append(url)
}
}
}
if (postAsPicture) {
val pictureMetas = buildPictureMetas(uploadResults)
publishPicture(
description = content,
images = pictureMetas,
account = account,
relayManager = relayManager,
)
} else {
val imetaTags = buildIMetaTags(uploadResults)
publishNote(
content = finalContent,
account = account,
relayManager = relayManager,
replyTo = replyTo,
imetaTags = imetaTags,
)
}
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,23 +346,176 @@ fun ComposeNoteDialog(
}
}
/**
* Publishes a text note to relays.
* Uses the Account's key to sign the event.
*/
private suspend fun publishNote(
content: String,
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)
}
@Composable
private fun ServerSelector(
selectedServer: String,
onServerSelected: (String) -> Unit,
) {
val servers = DesktopPreferences.blossomServers
if (servers.size <= 1) {
// Only one server — just show label, no dropdown
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text(
"Upload to: ",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
selectedServer,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
return
}
var expanded by remember { mutableStateOf(false) }
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text(
"Upload to: ",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Box {
androidx.compose.material3.TextButton(onClick = { expanded = true }) {
Text(
selectedServer,
style = MaterialTheme.typography.labelSmall,
)
}
androidx.compose.material3.DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
servers.forEach { server ->
androidx.compose.material3.DropdownMenuItem(
text = { Text(server, style = MaterialTheme.typography.bodySmall) },
onClick = {
onServerSelected(server)
expanded = false
},
)
}
}
}
}
}
@Composable
private fun PostTypeSelector(
isPicture: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Post as:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.material3.FilterChip(
selected = !isPicture,
onClick = { onToggle(false) },
label = { Text("Note", style = MaterialTheme.typography.labelSmall) },
)
androidx.compose.material3.FilterChip(
selected = isPicture,
onClick = { onToggle(true) },
label = { Text("Picture", style = MaterialTheme.typography.labelSmall) },
)
}
}
private fun buildPictureMetas(results: List<UploadResult>): List<com.vitorpamplona.quartz.nip68Picture.PictureMeta> =
results.mapNotNull { result ->
val url = result.blossom.url ?: return@mapNotNull null
val meta = result.metadata
com.vitorpamplona.quartz.nip68Picture.PictureMeta(
url = url,
mimeType = meta.mimeType,
blurhash = meta.blurhash,
dimension =
if (meta.width != null && meta.height != null) {
com.vitorpamplona.quartz.nip94FileMetadata.tags
.DimensionTag(meta.width, meta.height)
} else {
null
},
hash = meta.sha256,
size = meta.size.toInt(),
)
}
private suspend fun publishPicture(
description: String,
images: List<com.vitorpamplona.quartz.nip68Picture.PictureMeta>,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
) {
withContext(Dispatchers.IO) {
if (account.isReadOnly) {
throw IllegalStateException("Cannot post in read-only mode")
}
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
val template =
com.vitorpamplona.quartz.nip68Picture.PictureEvent.build(
images = images,
description = description,
) {
hashtags(findHashtags(description))
}
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
}
private suspend fun publishNote(
content: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
replyTo: Event?,
imetaTags: List<IMetaTag> = emptyList(),
) {
withContext(Dispatchers.IO) {
if (account.isReadOnly) {
throw IllegalStateException("Cannot post in read-only mode")
}
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)
}
}
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@@ -55,6 +55,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
@@ -75,7 +76,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -86,6 +89,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
data class LightboxState(
val urls: List<String>,
val index: Int,
val seekPosition: Float = 0f,
val fullscreen: Boolean = false,
)
/**
* Note card with action buttons.
*/
@@ -100,6 +110,8 @@ fun FeedNoteCard(
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
zapReceipts: List<ZapReceipt> = emptyList(),
reactionCount: Int = 0,
replyCount: Int = 0,
@@ -110,15 +122,15 @@ fun FeedNoteCard(
) {
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
Column(
modifier =
Modifier.clickable {
onNavigateToThread(event.id)
},
) {
Column {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = onImageClick,
onMediaClick = onMediaClick,
)
// Action buttons (only if logged in)
@@ -180,6 +192,7 @@ fun FeedScreen(
}
val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
@@ -434,30 +447,40 @@ fun FeedScreen(
)
}
// Subscribe to metadata for note authors (to enable zaps and populate search cache)
// Subscribe to metadata for note authors + mentioned users
val authorPubkeys = events.map { it.pubKey }.distinct()
val mentionedPubkeys =
remember(events) {
val parser = UrlParser()
events
.flatMap { event ->
val urls = parser.parseValidUrls(event.content)
extractMentionedPubkeys(urls.bech32s)
}.distinct()
}
val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() }
// Use coordinator for rate-limited metadata loading (preferred)
LaunchedEffect(authorPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys)
LaunchedEffect(allPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys)
}
}
// Fallback subscription if coordinator not available
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator
if (subscriptionsCoordinator != null) {
return@rememberSubscription null
}
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
authorPubkeys.filter { pubkey ->
allPubkeys.filter { pubkey ->
localCache
.getUserIfExists(pubkey)
?.metadataOrNull()
@@ -480,147 +503,156 @@ fun FeedScreen(
}
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button — wraps on narrow columns
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button — wraps on narrow columns
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Feed mode selector
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = {
feedMode = FeedMode.GLOBAL
DesktopPreferences.feedMode = FeedMode.GLOBAL
},
label = { Text("Global") },
// Feed mode selector
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = {
feedMode = FeedMode.GLOBAL
DesktopPreferences.feedMode = FeedMode.GLOBAL
},
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = {
feedMode = FeedMode.FOLLOWING
DesktopPreferences.feedMode = FeedMode.FOLLOWING
},
label = { Text("Following") },
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
"${followedUsers.size} followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = {
feedMode = FeedMode.FOLLOWING
DesktopPreferences.feedMode = FeedMode.FOLLOWING
},
label = { Text("Following") },
}
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { relayManager.connect() },
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
"${followedUsers.size} followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// New Post button (primary action)
Button(
onClick = onCompose,
enabled = account != null && !account.isReadOnly,
) {
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { relayManager.connect() },
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
Text("New Post")
}
}
Spacer(Modifier.height(8.dp))
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
LoadingState("Loading followed users...")
} else if (events.isEmpty() && !initialLoadComplete) {
LoadingState("Loading notes...")
} else if (events.isEmpty() && initialLoadComplete) {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users"
} else {
"No notes found"
},
description =
if (feedMode == FeedMode.FOLLOWING) {
"Notes from people you follow will appear here"
} else {
"Notes from the network will appear here"
},
onRefresh = { relayManager.connect() },
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Use distinctBy to prevent duplicate key crashes from events with same ID
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { replyToEvent = event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
}
// New Post button (primary action)
Button(
onClick = onCompose,
enabled = account != null && !account.isReadOnly,
) {
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("New Post")
}
}
Spacer(Modifier.height(8.dp))
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
LoadingState("Loading followed users...")
} else if (events.isEmpty() && !initialLoadComplete) {
LoadingState("Loading notes...")
} else if (events.isEmpty() && initialLoadComplete) {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users"
} else {
"No notes found"
},
description =
if (feedMode == FeedMode.FOLLOWING) {
"Notes from people you follow will appear here"
} else {
"Notes from the network will appear here"
},
onRefresh = { relayManager.connect() },
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Use distinctBy to prevent duplicate key crashes from events with same ID
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { replyToEvent = event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
}
} // end Column
// Reply dialog
if (replyToEvent != null && account != null) {
@@ -631,5 +663,16 @@ fun FeedScreen(
replyTo = replyToEvent,
)
}
}
// Lightbox overlay
lightboxState?.let { state ->
LightboxOverlay(
urls = state.urls,
initialIndex = state.index,
initialSeekPosition = state.seekPosition,
initialFullscreen = state.fullscreen,
onDismiss = { lightboxState = null },
)
}
} // end Box
}
@@ -449,6 +449,7 @@ fun SearchScreen(
state = state,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
localCache = localCache,
)
} else if (!debouncedQuery.isEmpty && !isSearching) {
Text(
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -51,6 +52,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
@@ -68,7 +70,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -135,12 +139,25 @@ fun ThreadScreen(
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Load metadata for thread authors via coordinator
// Lightbox state
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
// Load metadata for thread authors + mentioned users via coordinator
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null) {
val pubkeys = mutableListOf<String>()
rootNote?.let { pubkeys.add(it.pubKey) }
pubkeys.addAll(replyEvents.map { it.pubKey })
// Also load metadata for users mentioned in note content
val parser = UrlParser()
val allEvents = listOfNotNull(rootNote) + replyEvents
val mentionedPubkeys =
allEvents.flatMap { event ->
extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s)
}
pubkeys.addAll(mentionedPubkeys)
if (pubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
}
@@ -331,173 +348,200 @@ fun ThreadScreen(
return level
}
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(24.dp),
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(8.dp))
Text(
"Thread",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
Spacer(Modifier.width(8.dp))
Text(
"Thread",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (rootNote == null && !rootNoteEoseReceived) {
LoadingState("Loading thread...")
} else if (rootNote == null && rootNoteEoseReceived) {
EmptyState(
title = "Note not found",
description = "This note may have been deleted or is not available from connected relays",
onRefresh = onBack,
refreshLabel = "Go back",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note (no reply level indicator)
item(key = noteId) {
Column(
modifier =
Modifier.clickable {
// Already viewing this thread, no-op
},
) {
NoteCard(
note = rootNote!!.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList()
NoteActionsRow(
event = rootNote!!,
relayManager = relayManager,
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (rootNote == null && !rootNoteEoseReceived) {
LoadingState("Loading thread...")
} else if (rootNote == null && rootNoteEoseReceived) {
EmptyState(
title = "Note not found",
description = "This note may have been deleted or is not available from connected relays",
onRefresh = onBack,
refreshLabel = "Go back",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note (no reply level indicator)
item(key = noteId) {
Column {
NoteCard(
note = rootNote!!.toNoteDisplayData(localCache),
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onReply(rootNote!!) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = rootZaps.size,
zapAmountSats = rootZaps.sumOf { it.amountSats },
zapReceipts = rootZaps,
reactionCount = reactionsByEvent[noteId] ?: 0,
replyCount = repliesByEvent[noteId] ?: 0,
repostCount = repostsByEvent[noteId] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(noteId),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList()
NoteActionsRow(
event = rootNote!!,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onReply(rootNote!!) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = rootZaps.size,
zapAmountSats = rootZaps.sumOf { it.amountSats },
zapReceipts = rootZaps,
reactionCount = reactionsByEvent[noteId] ?: 0,
replyCount = repliesByEvent[noteId] ?: 0,
repostCount = repostsByEvent[noteId] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(noteId),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
HorizontalDivider(thickness = 1.dp)
}
HorizontalDivider(thickness = 1.dp)
}
// Reply notes with level indicators
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
val level = calculateLevel(event)
// Reply notes with level indicators
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
val level = calculateLevel(event)
Column(
modifier =
Modifier
.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.outlineVariant,
selected =
if (event.id == noteId) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
).clickable {
onNavigateToThread(event.id)
},
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
NoteActionsRow(
event = event,
relayManager = relayManager,
Column(
modifier =
Modifier
.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.outlineVariant,
selected =
if (event.id == noteId) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
).clickable {
onNavigateToThread(event.id)
},
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onReply(event) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = eventZaps.size,
zapAmountSats = eventZaps.sumOf { it.amountSats },
zapReceipts = eventZaps,
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onReply(event) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = eventZaps.size,
zapAmountSats = eventZaps.sumOf { it.amountSats },
zapReceipts = eventZaps,
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
HorizontalDivider(thickness = 1.dp)
}
HorizontalDivider(thickness = 1.dp)
}
// Empty state for no replies
if (replyEvents.isEmpty() && repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"Loading replies...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
// Empty state for no replies
if (replyEvents.isEmpty() && repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"Loading replies...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
} // end Column
// Lightbox overlay
val lb = lightboxState
if (lb != null) {
LightboxOverlay(
urls = lb.urls,
initialIndex = lb.index,
initialSeekPosition = lb.seekPosition,
initialFullscreen = lb.fullscreen,
onDismiss = { lightboxState = null },
)
}
}
} // end Box
}
/**
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
@@ -49,12 +54,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -79,12 +87,16 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -141,6 +153,11 @@ fun UserProfileScreen(
var postsError by remember { mutableStateOf<String?>(null) }
var retryTrigger by remember { mutableStateOf(0) }
// Tab and gallery state
var selectedTab by remember { mutableStateOf(0) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
// Follow state
val followState =
remember(account) {
@@ -300,343 +317,472 @@ fun UserProfileScreen(
}
}
Column(modifier = Modifier.fillMaxSize()) {
// Broadcast banner for profile updates
ProfileBroadcastBanner(
status = broadcastStatus,
onTap = {
// Clear banner on tap (could add retry logic for failed)
if (broadcastStatus is ProfileBroadcastStatus.Success ||
broadcastStatus is ProfileBroadcastStatus.Failed
) {
broadcastStatus = ProfileBroadcastStatus.Idle
}
},
)
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
Spacer(Modifier.width(8.dp))
Text(
"Profile",
style = MaterialTheme.typography.headlineMedium,
)
}
// Edit button for own profile
if (isOwnProfile && account.isReadOnly == false) {
OutlinedButton(
onClick = {
editingDisplayName = displayName ?: ""
showEditDialog = true
},
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit profile",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Edit Profile")
}
}
// Follow/Unfollow button for other profiles
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
Column(horizontalAlignment = Alignment.End) {
Button(
onClick = {
scope.launch {
val currentStatus = followState.currentStatusOrNull()
followState.setFollowLoading()
try {
val updatedEvent =
if (currentStatus?.isFollowing == true) {
unfollowUser(pubKeyHex, account, relayManager, myContactList)
} else {
followUser(pubKeyHex, account, relayManager, myContactList)
}
// Update both stored contact list and followState
myContactList = updatedEvent
followState.setFollowSuccess(updatedEvent, pubKeyHex)
} catch (e: Exception) {
e.printStackTrace()
followState.setFollowError(e.message ?: "Failed to update follow status", e)
}
}
},
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
) {
val state = followState.state.collectAsState().value
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
when {
!contactListLoaded -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text("Loading...")
}
isLoading -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollowing..." else "Following...")
}
else -> {
Icon(
if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
contentDescription = if (isFollowing) "Unfollow" else "Follow",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollow" else "Follow")
}
}
// Subscribe to picture events (kind 20) for gallery tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
pictureEvents.clear()
SubscriptionConfig(
subId = generateSubId("pics-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(PictureEvent.KIND),
limit = 100,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is PictureEvent && pictureEvents.none { it.id == event.id }) {
pictureEvents.add(event)
}
val errorMessage =
followState.state
.collectAsState()
.value
.errorOrNull()
errorMessage?.let { error ->
Spacer(Modifier.height(4.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Scroll state for detecting scroll direction
val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) }
var previousFirstVisibleItemIndex by remember { mutableStateOf(0) }
var previousFirstVisibleItemScrollOffset by remember { mutableStateOf(0) }
// Show floating header when scrolling up and header is scrolled out of view
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
val currentOffset = listState.firstVisibleItemScrollOffset
val scrollingUp =
currentIndex < previousFirstVisibleItemIndex ||
(currentIndex == previousFirstVisibleItemIndex && currentOffset < previousFirstVisibleItemScrollOffset)
// Header items are indices 0-3, so if first visible >= 3, header is out of view
showFloatingHeader = scrollingUp && currentIndex >= 3
if (!scrollingUp && currentIndex < 3) showFloatingHeader = false
previousFirstVisibleItemIndex = currentIndex
previousFirstVisibleItemScrollOffset = currentOffset
}
Box(modifier = Modifier.fillMaxSize()) {
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else {
// Profile card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Broadcast banner
item(key = "broadcast") {
ProfileBroadcastBanner(
status = broadcastStatus,
onTap = {
if (broadcastStatus is ProfileBroadcastStatus.Success ||
broadcastStatus is ProfileBroadcastStatus.Failed
) {
broadcastStatus = ProfileBroadcastStatus.Idle
}
},
)
}
// Header with back button
item(key = "header") {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Profile picture with robohash fallback
UserAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 56.dp,
contentDescription = "Profile picture",
)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
Spacer(Modifier.width(8.dp))
Text(
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
"Profile",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(4.dp))
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
var copied by remember { mutableStateOf(false) }
}
// Reset copied state after delay
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
// Edit button for own profile
if (isOwnProfile && account.isReadOnly == false) {
OutlinedButton(
onClick = {
editingDisplayName = displayName ?: ""
showEditDialog = true
},
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit profile",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Edit Profile")
}
}
// Follow/Unfollow button for other profiles
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
Column(horizontalAlignment = Alignment.End) {
Button(
onClick = {
scope.launch {
val currentStatus = followState.currentStatusOrNull()
followState.setFollowLoading()
try {
val updatedEvent =
if (currentStatus?.isFollowing == true) {
unfollowUser(pubKeyHex, account, relayManager, myContactList)
} else {
followUser(pubKeyHex, account, relayManager, myContactList)
}
// Update both stored contact list and followState
myContactList = updatedEvent
followState.setFollowSuccess(updatedEvent, pubKeyHex)
} catch (e: Exception) {
e.printStackTrace()
followState.setFollowError(e.message ?: "Failed to update follow status", e)
}
}
},
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
) {
val state = followState.state.collectAsState().value
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
when {
!contactListLoaded -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text("Loading...")
}
isLoading -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollowing..." else "Following...")
}
else -> {
Icon(
if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
contentDescription = if (isFollowing) "Unfollow" else "Follow",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollow" else "Follow")
}
}
}
val errorMessage =
followState.state
.collectAsState()
.value
.errorOrNull()
errorMessage?.let { error ->
Spacer(Modifier.height(4.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
// Profile card
item(key = "profile-card") {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
UserAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 56.dp,
contentDescription = "Profile picture",
)
Column(modifier = Modifier.weight(1f)) {
Text(
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (npub != null) {
IconButton(
onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(npub), null)
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy npub",
modifier = Modifier.size(14.dp),
tint =
if (copied) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (about != null) {
Spacer(Modifier.height(12.dp))
Text(
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
about!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (npub != null) {
IconButton(
onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(npub), null)
copied = true
},
modifier = Modifier.size(20.dp),
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column {
Text(
"$followersCount",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
"Followers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
Text(
"$followingCount",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
"Following",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// Tabs
item(key = "tabs") {
PrimaryTabRow(selectedTabIndex = selectedTab) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
Text("Notes", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text("Gallery", modifier = Modifier.padding(12.dp))
}
}
}
// Tab content
when (selectedTab) {
0 -> {
when {
postsError != null -> {
item(key = "error") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Icon(
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy npub",
modifier = Modifier.size(14.dp),
tint =
if (copied) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load posts",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
postsError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
}
}
}
}
}
postsLoading -> {
item(key = "loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading posts...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
events.isEmpty() -> {
item(key = "empty") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No posts yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
if (about != null) {
Spacer(Modifier.height(12.dp))
Text(
about!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column {
Text(
"$followersCount",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
"Followers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
Text(
"$followingCount",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
"Following",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(Modifier.height(16.dp))
// User's posts
Text(
"Posts",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
when {
postsError != null -> {
// Error state with retry
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load posts",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
postsError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
else -> {
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
}
}
postsLoading -> {
// Loading state
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading posts...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
events.isEmpty() -> {
// Empty state (loaded but no posts)
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No posts yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
// Posts loaded successfully
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
1 -> {
item(key = "gallery") {
GalleryTab(
pictureEvents = pictureEvents,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
modifier = Modifier.fillParentMaxHeight(),
)
}
}
}
}
}
// Floating header — appears on scroll up when profile header is out of view
AnimatedVisibility(
visible = showFloatingHeader,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
Spacer(Modifier.width(8.dp))
UserAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
size = 28.dp,
contentDescription = "Profile picture",
)
Spacer(Modifier.width(8.dp))
Text(
displayName ?: pubKeyHex.take(12) + "...",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
// Lightbox overlay
lightboxState?.let { state ->
LightboxOverlay(
urls = state.urls,
initialIndex = state.index,
initialSeekPosition = state.seekPosition,
initialFullscreen = state.fullscreen,
onDismiss = { lightboxState = null },
)
}
// Edit Profile Dialog
@@ -0,0 +1,294 @@
/*
* 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.chats
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
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.heightIn
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.Download
import androidx.compose.material.icons.filled.Forward
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import org.jetbrains.skia.Image as SkiaImage
@Composable
fun ChatFileAttachment(
event: ChatMessageEncryptedFileHeaderEvent,
onForward: ((ChatMessageEncryptedFileHeaderEvent) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val url = event.url()
val mimeType = event.mimeType()
val keyBytes = event.key()
val nonce = event.nonce()
val isImage = mimeType?.startsWith("image/") == true
val scope = rememberCoroutineScope()
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
// Auto-decrypt images
LaunchedEffect(url) {
if (keyBytes != null && nonce != null && url != null) {
isLoading = true
try {
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
decryptedBytes = bytes
if (isImage) {
withContext(Dispatchers.Default) {
val skImage = SkiaImage.makeFromEncoded(bytes)
decryptedImage = skImage.toComposeImageBitmap()
}
}
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
}
Box {
Card(
modifier =
modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
// Right-click detection handled via awaiting press
// Context menu is shown on secondary button via the other modifier
},
)
}.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.buttons.isSecondaryPressed &&
event.changes.any { it.pressed && !it.previousPressed }
) {
showContextMenu = true
}
}
}
},
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Column(modifier = Modifier.padding(8.dp)) {
// Encryption indicator
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Lock,
contentDescription = "Encrypted",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(4.dp))
Text(
"Encrypted file",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(4.dp))
when {
isLoading -> {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.CenterHorizontally),
)
}
decryptedImage != null -> {
androidx.compose.foundation.Image(
bitmap = decryptedImage!!,
contentDescription = "Encrypted image",
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.FillWidth,
)
}
error != null -> {
Text(
"Failed to decrypt: $error",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
else -> {
// Non-image file
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = "File",
modifier = Modifier.size(32.dp),
)
Spacer(Modifier.width(8.dp))
Column {
Text(
mimeType ?: "Unknown file",
style = MaterialTheme.typography.bodySmall,
)
event.size()?.let { size ->
Text(
"${size / 1024}KB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
// Right-click context menu
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
) {
DropdownMenuItem(
text = { Text("Save to disk") },
leadingIcon = {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
},
enabled = decryptedBytes != null,
onClick = {
showContextMenu = false
scope.launch {
saveDecryptedFile(decryptedBytes!!, mimeType, event.hash())
}
},
)
if (onForward != null) {
DropdownMenuItem(
text = { Text("Forward") },
leadingIcon = {
Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(18.dp))
},
onClick = {
showContextMenu = false
onForward(event)
},
)
}
}
}
}
/**
* Save decrypted bytes to disk via a native save dialog.
*/
private suspend fun saveDecryptedFile(
bytes: ByteArray,
mimeType: String?,
hash: String?,
) {
val extension = mimeTypeToExtension(mimeType)
val suggestedName = "${hash?.take(12) ?: "file"}.$extension"
val file =
withContext(Dispatchers.Main) {
val dialog =
FileDialog(null as Frame?, "Save Decrypted File", FileDialog.SAVE).apply {
this.file = suggestedName
}
dialog.isVisible = true
val dir = dialog.directory ?: return@withContext null
File(dir, dialog.file ?: return@withContext null)
} ?: return
withContext(Dispatchers.IO) {
file.writeBytes(bytes)
}
}
private fun mimeTypeToExtension(mimeType: String?): String =
when (mimeType) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
"image/svg+xml" -> "svg"
"video/mp4" -> "mp4"
"video/webm" -> "webm"
"video/quicktime" -> "mov"
"audio/mpeg" -> "mp3"
"audio/ogg" -> "ogg"
"audio/wav" -> "wav"
"audio/flac" -> "flac"
else -> "bin"
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.border
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -37,7 +39,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.outlined.AddReaction
@@ -46,6 +50,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -53,6 +59,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -60,6 +67,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
@@ -88,14 +97,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.launch
import java.awt.datatransfer.DataFlavor
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTargetDropEvent
import java.io.File
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
private val MEDIA_EXTENSIONS =
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
/**
* Right panel of the DM split-pane layout (flexible width).
*
@@ -111,6 +134,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
* @param messageState ChatNewMessageState for composition
* @param onNavigateToProfile Called when user clicks on a profile
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ChatPane(
roomKey: ChatroomKey,
@@ -120,6 +144,7 @@ fun ChatPane(
messageState: ChatNewMessageState,
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
onNavigateToProfile: (String) -> Unit = {},
onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -128,6 +153,55 @@ fun ChatPane(
val isNip17 by messageState.nip17.collectAsState()
val requiresNip17 by messageState.requiresNip17.collectAsState()
// File attachment state
val attachedFiles = remember { mutableStateListOf<File>() }
var isUploading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
// Helper: attach files and auto-force NIP-17 if needed
fun attachFiles(files: List<File>) {
val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }
if (mediaFiles.isEmpty()) return
attachedFiles.addAll(mediaFiles)
if (!isNip17) {
messageState.toggleNip17()
scope.launch {
snackbarHostState.showSnackbar("Switched to NIP-17 — file attachments require encrypted messaging")
}
}
}
// Drag-and-drop target for file attachments (NIP-17 only)
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>
attachFiles(files)
dropEvent.dropComplete(true)
return true
}
dropEvent.dropComplete(false)
return false
}
override fun onStarted(event: DragAndDropEvent) {
isDragOver = true
}
override fun onEnded(event: DragAndDropEvent) {
isDragOver = false
}
}
}
// Resolve users for the header
val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
val isGroup = users.size > 1
@@ -137,110 +211,186 @@ fun ChatPane(
messageState.load(roomKey)
}
Column(modifier = modifier.fillMaxSize()) {
// Header
if (isGroup) {
GroupChatroomHeader(
users = users,
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
)
} else {
users.firstOrNull()?.let { user ->
ChatroomHeader(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
} ?: run {
// Fallback header with raw pubkey
Text(
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(10.dp),
)
}
}
HorizontalDivider()
// Broadcast status banner
DmBroadcastBanner(status = dmBroadcastStatus)
// Message list
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (feedState) {
is FeedState.Loading -> {
LoadingState("Loading messages...")
}
is FeedState.Empty -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
"No messages yet. Send the first one!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is FeedState.Loaded -> {
val loaded = feedState as FeedState.Loaded
val loadedState by loaded.feed.collectAsState()
val messages = loadedState.list
MessageList(
messages = messages,
account = account,
cacheProvider = cacheProvider,
onAuthorClick = onNavigateToProfile,
onReaction = { note, emoji ->
scope.launch {
try {
sendWrappedReaction(note, emoji, roomKey, account)
} catch (e: Exception) {
println("Failed to send reaction: ${e.message}")
}
}
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier =
Modifier
.fillMaxSize()
.dragAndDropTarget(
shouldStartDragAndDrop = { true },
target = dropTarget,
).then(
if (isDragOver) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
} else {
Modifier
},
)
}
is FeedState.FeedError -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
),
) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
if (onBack != null) {
IconButton(
onClick = onBack,
modifier = Modifier.size(40.dp),
) {
Text(
"Error loading messages",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to conversations",
)
}
}
}
}
HorizontalDivider()
// Message input
MessageInput(
messageText = messageText.text,
isNip17 = isNip17,
requiresNip17 = requiresNip17,
canSend = messageState.canSend,
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
onToggleNip17 = { messageState.toggleNip17() },
onSend = {
scope.launch {
if (messageState.send()) {
messageState.clear()
Box(modifier = Modifier.weight(1f)) {
if (isGroup) {
GroupChatroomHeader(
users = users,
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
)
} else {
users.firstOrNull()?.let { user ->
ChatroomHeader(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
} ?: run {
// Fallback header with raw pubkey
Text(
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(10.dp),
)
}
}
}
},
}
HorizontalDivider()
// Broadcast status banner
DmBroadcastBanner(status = dmBroadcastStatus)
// Message list
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (feedState) {
is FeedState.Loading -> {
LoadingState("Loading messages...")
}
is FeedState.Empty -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
"No messages yet. Send the first one!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is FeedState.Loaded -> {
val loaded = feedState as FeedState.Loaded
val loadedState by loaded.feed.collectAsState()
val messages = loadedState.list
MessageList(
messages = messages,
account = account,
cacheProvider = cacheProvider,
onAuthorClick = onNavigateToProfile,
onReaction = { note, emoji ->
scope.launch {
try {
sendWrappedReaction(note, emoji, roomKey, account)
} catch (e: Exception) {
println("Failed to send reaction: ${e.message}")
}
}
},
)
}
is FeedState.FeedError -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
"Error loading messages",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
HorizontalDivider()
// File attachment row (only when NIP-17 and files attached)
if (attachedFiles.isNotEmpty()) {
MediaAttachmentRow(
attachedFiles = attachedFiles,
isUploading = isUploading,
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
onPaste = {},
onRemove = { attachedFiles.remove(it) },
)
}
// Message input
MessageInput(
messageText = messageText.text,
isNip17 = isNip17,
requiresNip17 = requiresNip17,
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
isUploading = isUploading,
hasAttachments = attachedFiles.isNotEmpty(),
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
onToggleNip17 = { messageState.toggleNip17() },
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
onSend = {
scope.launch {
if (attachedFiles.isNotEmpty()) {
isUploading = true
try {
sendEncryptedFiles(
files = attachedFiles.toList(),
roomKey = roomKey,
account = account,
cacheProvider = cacheProvider,
)
attachedFiles.clear()
} catch (e: Exception) {
// Keep files in attachment row for retry
println("Encrypted file send failed: ${e.message}")
} finally {
isUploading = false
}
}
// Also send text message if present
if (messageState.canSend) {
if (messageState.send()) {
messageState.clear()
}
} else if (attachedFiles.isEmpty()) {
messageState.clear()
}
}
},
)
} // end Column
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp),
)
}
} // end Box
}
/**
@@ -373,6 +523,29 @@ private fun MessageWithReactions(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Encryption badge
when (event) {
is PrivateDmEvent -> {
Icon(
Icons.Default.LockOpen,
contentDescription = "NIP-04 (legacy)",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
}
is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent,
is ChatMessageEncryptedFileHeaderEvent,
-> {
Icon(
Icons.Default.Lock,
contentDescription = "NIP-17 (encrypted)",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
)
}
}
// Timestamp
note.createdAt()?.let { timestamp ->
Text(
@@ -436,12 +609,20 @@ private fun MessageWithReactions(
}
},
) { _ ->
SelectionContainer {
Text(
text = decryptedContent ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
when (note.event) {
is ChatMessageEncryptedFileHeaderEvent -> {
ChatFileAttachment(event = note.event as ChatMessageEncryptedFileHeaderEvent)
}
else -> {
SelectionContainer {
Text(
text = decryptedContent ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
@@ -520,8 +701,11 @@ private fun MessageInput(
isNip17: Boolean,
requiresNip17: Boolean,
canSend: Boolean,
isUploading: Boolean = false,
hasAttachments: Boolean = false,
onMessageChange: (String) -> Unit,
onToggleNip17: () -> Unit,
onAttach: () -> Unit = {},
onSend: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
@@ -530,6 +714,24 @@ private fun MessageInput(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Paperclip attach button — always visible, auto-switches to NIP-17 on send
IconButton(
onClick = onAttach,
enabled = !isUploading,
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.Default.AttachFile,
contentDescription = "Attach file",
tint =
if (isUploading) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.primary
},
)
}
OutlinedTextField(
value = messageText,
onValueChange = onMessageChange,
@@ -555,14 +757,14 @@ private fun MessageInput(
IconButton(
onClick = onSend,
enabled = canSend,
enabled = canSend && !isUploading,
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint =
if (canSend) {
if (canSend && !isUploading) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
@@ -616,3 +818,43 @@ private fun MessageInput(
}
}
}
/**
* Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15)
* wrapped in GiftWrap for each recipient.
*/
private suspend fun sendEncryptedFiles(
files: List<File>,
roomKey: ChatroomKey,
account: IAccount,
cacheProvider: ICacheProvider,
) {
val orchestrator = DesktopUploadOrchestrator()
val server = DesktopPreferences.preferredBlossomServer
val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() }
for (file in files) {
val cipher = AESGCM()
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
val url = result.blossom.url ?: continue
val template =
ChatMessageEncryptedFileHeaderEvent.build(
to = recipients,
url = url,
cipher = cipher,
mimeType = result.metadata.mimeType,
hash = result.encryptedHash,
size = result.encryptedSize,
dimension =
if (result.metadata.width != null && result.metadata.height != null) {
DimensionTag(result.metadata.width, result.metadata.height)
} else {
null
},
blurhash = result.metadata.blurhash,
originalHash = result.metadata.sha256,
)
account.sendNip17EncryptedFile(template)
}
}
@@ -119,7 +119,6 @@ fun ConversationListPane(
Column(
modifier =
modifier
.width(280.dp)
.fillMaxHeight()
.focusRequester(focusRequester)
.focusable()
@@ -24,8 +24,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Icon
@@ -59,18 +61,19 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.CoroutineScope
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
/**
* Desktop DM screen with split-pane layout.
* Desktop DM screen with two layout modes:
*
* Left pane (280dp): ConversationListPane with Known/New tabs
* Right pane (flex): ChatPane with messages + input, or empty state
* - **Split mode** (compactMode = false): Side-by-side with conversation list (280dp) + chat pane.
* Used in single-pane layout where there's plenty of horizontal space.
*
* @param account The user's IAccount for DM operations
* @param cacheProvider ICacheProvider for user/note lookups
* @param onNavigateToProfile Called when navigating to a user profile
* - **Compact mode** (compactMode = true): Stacked navigation full-width contact list OR
* full-width chat. Used in multi-deck columns where width is limited.
*/
@Composable
fun DesktopMessagesScreen(
@@ -78,6 +81,7 @@ fun DesktopMessagesScreen(
cacheProvider: ICacheProvider,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
compactMode: Boolean = false,
onNavigateToProfile: (String) -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -89,51 +93,159 @@ fun DesktopMessagesScreen(
val listFocusRequester = remember { FocusRequester() }
var showNewDmDialog by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
// Shared keyboard shortcuts
val keyHandler =
Modifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
when {
// Escape -> deselect conversation
event.key == Key.Escape -> {
listState.clearSelection()
true
}
when {
event.key == Key.Escape -> {
listState.clearSelection()
true
}
// Cmd+Shift+N / Ctrl+Shift+N -> new DM
event.key == Key.N && isModifier && event.isShiftPressed -> {
showNewDmDialog = true
true
}
event.key == Key.N && isModifier && event.isShiftPressed -> {
showNewDmDialog = true
true
}
else -> {
false
}
}
},
) {
// Left pane: conversation list (280dp fixed)
else -> {
false
}
}
}
if (compactMode) {
CompactMessagesContent(
selectedRoom = selectedRoom,
listState = listState,
account = account,
cacheProvider = cacheProvider,
scope = scope,
onNavigateToProfile = onNavigateToProfile,
listFocusRequester = listFocusRequester,
onShowNewDm = { showNewDmDialog = true },
keyHandler = keyHandler,
)
} else {
SplitMessagesContent(
selectedRoom = selectedRoom,
listState = listState,
account = account,
cacheProvider = cacheProvider,
scope = scope,
onNavigateToProfile = onNavigateToProfile,
listFocusRequester = listFocusRequester,
onShowNewDm = { showNewDmDialog = true },
keyHandler = keyHandler,
)
}
if (showNewDmDialog) {
NewDmDialog(
cacheProvider = cacheProvider,
relayManager = relayManager,
localCache = localCache,
onUserSelected = { roomKey ->
listState.selectRoom(roomKey)
showNewDmDialog = false
},
onDismiss = { showNewDmDialog = false },
)
}
}
/**
* Compact (stacked) layout for deck columns.
* Shows either the contact list OR the chat, never both.
*/
@Composable
private fun CompactMessagesContent(
selectedRoom: ChatroomKey?,
listState: ChatroomListState,
account: IAccount,
cacheProvider: ICacheProvider,
scope: CoroutineScope,
onNavigateToProfile: (String) -> Unit,
listFocusRequester: FocusRequester,
onShowNewDm: () -> Unit,
keyHandler: Modifier,
) {
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
val currentRoom = selectedRoom
if (currentRoom != null) {
val feedViewModel =
remember(currentRoom) {
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
}
val messageState =
remember(currentRoom) {
ChatNewMessageState(account, cacheProvider, scope)
}
val broadcastStatus =
if (account is DesktopIAccount) {
account.dmSendTracker.status
.collectAsState()
.value
} else {
DmBroadcastStatus.Idle
}
ChatPane(
roomKey = currentRoom,
account = account,
cacheProvider = cacheProvider,
feedViewModel = feedViewModel,
messageState = messageState,
dmBroadcastStatus = broadcastStatus,
onNavigateToProfile = onNavigateToProfile,
onBack = { listState.clearSelection() },
)
} else {
ConversationListPane(
state = listState,
selectedRoom = selectedRoom,
onConversationSelected = { listState.selectRoom(it) },
onNewConversation = onShowNewDm,
focusRequester = listFocusRequester,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
/**
* Split (side-by-side) layout for single-pane mode.
* Contact list (280dp) + divider + chat pane (flex).
*/
@Composable
private fun SplitMessagesContent(
selectedRoom: ChatroomKey?,
listState: ChatroomListState,
account: IAccount,
cacheProvider: ICacheProvider,
scope: CoroutineScope,
onNavigateToProfile: (String) -> Unit,
listFocusRequester: FocusRequester,
onShowNewDm: () -> Unit,
keyHandler: Modifier,
) {
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
ConversationListPane(
state = listState,
selectedRoom = selectedRoom,
onConversationSelected = { roomKey ->
listState.selectRoom(roomKey)
},
onNewConversation = { showNewDmDialog = true },
onConversationSelected = { listState.selectRoom(it) },
onNewConversation = onShowNewDm,
focusRequester = listFocusRequester,
modifier = Modifier.width(280.dp),
)
VerticalDivider(modifier = Modifier.fillMaxHeight())
// Right pane: chat or empty state (flex)
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
val currentRoom = selectedRoom
if (currentRoom != null) {
// Create feed VM and message state scoped to the selected room
val feedViewModel =
remember(currentRoom) {
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
@@ -142,7 +254,6 @@ fun DesktopMessagesScreen(
remember(currentRoom) {
ChatNewMessageState(account, cacheProvider, scope)
}
val broadcastStatus =
if (account is DesktopIAccount) {
account.dmSendTracker.status
@@ -162,28 +273,14 @@ fun DesktopMessagesScreen(
onNavigateToProfile = onNavigateToProfile,
)
} else {
// Empty state
EmptyConversationState()
}
}
}
if (showNewDmDialog) {
NewDmDialog(
cacheProvider = cacheProvider,
relayManager = relayManager,
localCache = localCache,
onUserSelected = { roomKey ->
listState.selectRoom(roomKey)
showNewDmDialog = false
},
onDismiss = { showNewDmDialog = false },
)
}
}
/**
* Shown when no conversation is selected in the right pane.
* Shown when no conversation is selected in the split layout right pane.
*/
@Composable
private fun EmptyConversationState() {
@@ -55,7 +55,6 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -90,6 +89,7 @@ fun DeckColumnContainer(
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
@@ -129,9 +129,11 @@ fun DeckColumnContainer(
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
@@ -170,9 +172,11 @@ internal fun RootContent(
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
@@ -202,19 +206,12 @@ internal fun RootContent(
}
DeckColumnType.Messages -> {
val dmSendTracker =
remember(relayManager) {
DmSendTracker(relayManager.client)
}
val iAccount =
remember(account, localCache, relayManager, dmSendTracker) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope)
}
DesktopMessagesScreen(
account = iAccount,
cacheProvider = localCache,
relayManager = relayManager,
localCache = localCache,
compactMode = compactMode,
onNavigateToProfile = onNavigateToProfile,
)
}
@@ -58,6 +58,7 @@ fun DeckLayout(
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
@@ -109,6 +110,7 @@ fun DeckLayout(
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
@@ -93,6 +94,7 @@ fun SinglePaneLayout(
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
@@ -108,50 +110,56 @@ fun SinglePaneLayout(
val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull()
val isImmersive by LocalIsImmersiveFullscreen.current
Row(modifier = modifier.fillMaxSize()) {
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
navItems.forEach { item ->
NavigationRailItem(
selected = currentColumnType == item.type && navStack.isEmpty(),
onClick = {
currentColumnType = item.type
navState.clear()
},
icon = {
Icon(
item.icon,
contentDescription = item.label,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
item.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
if (!isImmersive) {
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
navItems.forEach { item ->
NavigationRailItem(
selected = currentColumnType == item.type && navStack.isEmpty(),
onClick = {
currentColumnType = item.type
navState.clear()
},
icon = {
Icon(
item.icon,
contentDescription = item.label,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
item.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 12.dp),
)
}
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 12.dp),
)
}
VerticalDivider()
if (!isImmersive) {
VerticalDivider()
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp),
) {
// Always keep RootContent composed so state (e.g. search results) survives navigation
RootContent(
@@ -160,6 +168,7 @@ fun SinglePaneLayout(
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
@@ -0,0 +1,176 @@
/*
* 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.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Codec
import org.jetbrains.skia.Data
import java.util.concurrent.TimeUnit
private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF
private const val MIN_FRAME_DURATION_MS = 20
private val gifHttpClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
fun isAnimatedGifUrl(url: String): Boolean {
val lower = url.lowercase()
return lower.endsWith(".gif") ||
lower.contains(".gif?") ||
lower.contains(".gif#")
}
private class GifFrames(
val frames: List<ImageBitmap>,
val durations: List<Int>,
)
@Composable
fun AnimatedGifImage(
url: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
var gifFrames by remember(url) { mutableStateOf<GifFrames?>(null) }
var currentFrame by remember(url) { mutableIntStateOf(0) }
var loadFailed by remember(url) { mutableStateOf(false) }
LaunchedEffect(url) {
currentFrame = 0
loadFailed = false
gifFrames = withContext(Dispatchers.IO) { decodeGifFrames(url) }
if (gifFrames == null) loadFailed = true
}
// Reset frame index when frames change
DisposableEffect(url) {
onDispose { currentFrame = 0 }
}
val data = gifFrames
when {
data != null && data.frames.size > 1 -> {
LaunchedEffect(data) {
while (isActive) {
val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS)
delay(duration.toLong())
currentFrame = (currentFrame + 1) % data.frames.size
}
}
Image(
bitmap = data.frames[currentFrame],
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}
data != null -> {
Image(
bitmap = data.frames[0],
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}
loadFailed -> {
AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}
else -> {
Box(modifier)
}
}
}
private fun decodeGifFrames(url: String): GifFrames? =
try {
val request = Request.Builder().url(url).build()
val response = gifHttpClient.newCall(request).execute()
val bytes = response.body.bytes()
val skData = Data.makeFromBytes(bytes)
val codec = Codec.makeFromData(skData)
val frameCount = codec.frameCount
if (frameCount <= 0) return null
val frameBitmapSize = codec.width.toLong() * codec.height * 4
val totalMemory = frameBitmapSize * frameCount
val decodableFrames =
if (totalMemory > MAX_BITMAP_MEMORY) {
// Only decode first frame for huge GIFs
1
} else {
frameCount
}
val frameInfos = codec.framesInfo
val frames = ArrayList<ImageBitmap>(decodableFrames)
val durations = ArrayList<Int>(decodableFrames)
for (i in 0 until decodableFrames) {
val bitmap = Bitmap()
bitmap.allocN32Pixels(codec.width, codec.height)
codec.readPixels(bitmap, i)
bitmap.setImmutable()
frames.add(bitmap.asComposeImageBitmap())
durations.add(if (frameInfos.size > i) frameInfos[i].duration else 100)
}
GifFrames(frames, durations)
} catch (e: Exception) {
println("AnimatedGif: failed to load $url${e.message}")
null
}
@@ -0,0 +1,111 @@
/*
* 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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
@Composable
fun AudioPlayer(
url: String,
modifier: Modifier = Modifier,
) {
val audioState by GlobalMediaPlayer.audioState.collectAsState()
val isActiveAudio = audioState.url == url
val isPlaying = if (isActiveAudio) audioState.isPlaying else false
val position = if (isActiveAudio) audioState.position else 0f
val duration = if (isActiveAudio) audioState.duration else 0L
val currentTime = if (isActiveAudio) audioState.currentTime else 0L
Row(
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
IconButton(
onClick = {
if (isActiveAudio) {
GlobalMediaPlayer.toggleAudioPlayPause()
} else {
GlobalMediaPlayer.playAudio(url)
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(20.dp),
)
}
Text(
text = formatTime(currentTime),
style = MaterialTheme.typography.labelSmall,
)
Slider(
value = position,
onValueChange = { GlobalMediaPlayer.seekAudio(it) },
modifier = Modifier.weight(1f),
)
Text(
text = formatTime(duration),
style = MaterialTheme.typography.labelSmall,
)
}
}
@@ -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,196 @@
/*
* 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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import kotlinx.coroutines.delay
@Composable
fun DesktopVideoPlayer(
url: String,
modifier: Modifier = Modifier,
autoPlay: Boolean = false,
initialSeekPosition: Float = 0f,
onFullscreen: ((Float) -> Unit)? = null,
viewMode: ViewMode = ViewMode.DEFAULT,
onViewModeChange: ((ViewMode) -> Unit)? = null,
trailingControls: @Composable (() -> Unit)? = null,
) {
// Check if this URL is the active video
val videoState by GlobalMediaPlayer.videoState.collectAsState()
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
val isActiveVideo = videoState.url == url
// Thumbnail for inactive videos
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
// Load thumbnail when not active
LaunchedEffect(url, isActiveVideo) {
if (!isActiveVideo && thumbnail == null) {
for (attempt in 1..3) {
val result = VideoThumbnailCache.getThumbnail(url)
if (result != null) {
thumbnail = result
break
}
if (attempt < 3) delay(2000L * attempt)
}
}
}
// Auto-play on mount if requested
LaunchedEffect(url, autoPlay) {
if (autoPlay) {
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
}
}
// Sync aspect ratio from global state when active
if (isActiveVideo && videoState.aspectRatio != 16f / 9f) {
aspectRatio = videoState.aspectRatio
}
if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) {
VlcNotAvailableMessage(url, modifier)
return
}
BoxWithConstraints(modifier = modifier) {
val desiredHeight = maxWidth / aspectRatio
val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight
Box(
modifier =
Modifier
.fillMaxWidth()
.height(constrainedHeight)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center,
) {
val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail
displayBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = "Video",
modifier =
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
)
}
VideoControls(
isPlaying = if (isActiveVideo) videoState.isPlaying else false,
isBuffering = if (isActiveVideo) videoState.isBuffering else false,
position = if (isActiveVideo) videoState.position else 0f,
duration = if (isActiveVideo) videoState.duration else 0L,
currentTime = if (isActiveVideo) videoState.currentTime else 0L,
volume = if (isActiveVideo) videoState.volume else 100,
isMuted = if (isActiveVideo) videoState.isMuted else false,
viewMode = viewMode,
onPlayPause = {
if (isActiveVideo) {
GlobalMediaPlayer.toggleVideoPlayPause()
} else {
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
}
},
onSeek = { pos ->
if (isActiveVideo) {
GlobalMediaPlayer.seekVideo(pos)
}
},
onVolumeChange = { vol ->
GlobalMediaPlayer.setVideoVolume(vol)
},
onMuteToggle = {
GlobalMediaPlayer.toggleVideoMute()
},
onFullscreen =
if (onFullscreen != null) {
{
val pos = if (isActiveVideo) videoState.position else 0f
onFullscreen(pos)
}
} else {
null
},
onViewModeChange = onViewModeChange,
trailingControls = trailingControls,
)
}
}
}
@Composable
private fun VlcNotAvailableMessage(
url: String,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center,
) {
Text(
text = "Video: $url\nInstall VLC to play videos: https://www.videolan.org/vlc/",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -0,0 +1,43 @@
/*
* 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.GraphicsEnvironment
import java.awt.Window
object FullscreenHelper {
fun enterFullscreen(window: Window) {
val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice
if (device.isFullScreenSupported) {
device.fullScreenWindow = window
}
}
fun exitFullscreen() {
val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice
device.fullScreenWindow = null
}
fun isFullscreen(): Boolean {
val device = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice
return device.fullScreenWindow != null
}
}
@@ -0,0 +1,137 @@
/*
* 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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
@Composable
fun GlobalFullscreenOverlay() {
val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState()
val videoState by GlobalMediaPlayer.videoState.collectAsState()
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
if (!isFullscreen || videoState.url == null) return
val focusRequester = remember { FocusRequester() }
val awtWindow = LocalAwtWindow.current
val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current
// Enter native fullscreen
LaunchedEffect(isFullscreen) {
if (isFullscreen) {
isImmersiveFullscreen.value = true
awtWindow?.let { FullscreenHelper.enterFullscreen(it) }
focusRequester.requestFocus()
}
}
// Restore on exit
DisposableEffect(Unit) {
onDispose {
isImmersiveFullscreen.value = false
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
}
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(focusRequester)
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.Escape -> {
GlobalMediaPlayer.exitFullscreen()
true
}
Key.F -> {
GlobalMediaPlayer.exitFullscreen()
true
}
Key.Spacebar -> {
GlobalMediaPlayer.toggleVideoPlayPause()
true
}
else -> {
false
}
}
},
contentAlignment = Alignment.Center,
) {
// Video frame
videoFrame?.let { frame ->
Image(
bitmap = frame,
contentDescription = "Video fullscreen",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
}
// Video controls overlay
VideoControls(
isPlaying = videoState.isPlaying,
isBuffering = videoState.isBuffering,
position = videoState.position,
duration = videoState.duration,
currentTime = videoState.currentTime,
volume = videoState.volume,
isMuted = videoState.isMuted,
viewMode = ViewMode.FULLSCREEN,
onPlayPause = { GlobalMediaPlayer.toggleVideoPlayPause() },
onSeek = { GlobalMediaPlayer.seekVideo(it) },
onVolumeChange = { GlobalMediaPlayer.setVideoVolume(it) },
onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() },
onViewModeChange = { mode ->
if (mode == ViewMode.DEFAULT) {
GlobalMediaPlayer.exitFullscreen()
}
},
)
}
}
@@ -0,0 +1,521 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowState
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.awt.Desktop
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.io.File
import java.net.URI
val LocalWindowState = compositionLocalOf<androidx.compose.ui.window.WindowState?> { null }
val LocalAwtWindow = compositionLocalOf<java.awt.Window?> { null }
val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) }
enum class ViewMode { DEFAULT, FULLSCREEN }
private sealed class DownloadState {
data object Idle : DownloadState()
data class Downloading(
val progress: Float,
val filename: String,
) : DownloadState()
data class Done(
val file: File,
) : DownloadState()
data class Failed(
val message: String,
) : DownloadState()
}
@Composable
fun LightboxOverlay(
urls: List<String>,
initialIndex: Int = 0,
initialSeekPosition: Float = 0f,
initialFullscreen: Boolean = false,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) }
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var menuExpanded by remember { mutableStateOf(false) }
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
var viewMode by remember { mutableStateOf(if (initialFullscreen) ViewMode.FULLSCREEN else ViewMode.DEFAULT) }
val awtWindow = LocalAwtWindow.current
val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
// Sync exclusive fullscreen with viewMode and signal parent layouts
LaunchedEffect(viewMode) {
isImmersiveFullscreen.value = viewMode == ViewMode.FULLSCREEN
if (viewMode == ViewMode.FULLSCREEN) {
awtWindow?.let { FullscreenHelper.enterFullscreen(it) }
} else {
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
}
}
// Restore fullscreen on dismiss
DisposableEffect(Unit) {
onDispose {
isImmersiveFullscreen.value = false
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
}
}
// Auto-dismiss banner after 3s
LaunchedEffect(downloadState) {
if (downloadState is DownloadState.Done || downloadState is DownloadState.Failed) {
delay(3000)
downloadState = DownloadState.Idle
}
}
val currentUrl = urls[currentIndex]
val isVideo = RichTextParser.isVideoUrl(currentUrl)
fun triggerSave() {
if (downloadState is DownloadState.Downloading) return
val url = urls[currentIndex]
val filename = url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
downloadState = DownloadState.Downloading(progress = -1f, filename = filename)
scope.launch {
val result =
SaveMediaAction.saveMedia(
url = url,
onProgress = { downloaded, total ->
val progress = if (total > 0) downloaded.toFloat() / total else -1f
downloadState = DownloadState.Downloading(progress = progress, filename = filename)
},
)
downloadState =
if (result != null) {
DownloadState.Done(result)
} else {
DownloadState.Failed("Download failed")
}
}
}
fun toggleFullscreen() {
viewMode =
if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN
}
// Content modifier based on view mode
val contentModifier =
if (viewMode == ViewMode.DEFAULT) {
Modifier.fillMaxSize().padding(48.dp)
} else {
Modifier.fillMaxSize()
}
Box(
modifier =
modifier
.fillMaxSize()
.background(if (viewMode == ViewMode.FULLSCREEN) Color.Black else Color.Black.copy(alpha = 0.9f))
.focusRequester(focusRequester)
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.Escape -> {
if (viewMode == ViewMode.FULLSCREEN) {
viewMode = ViewMode.DEFAULT
} else {
onDismiss()
}
true
}
Key.F -> {
toggleFullscreen()
true
}
Key.DirectionLeft -> {
if (currentIndex > 0) currentIndex--
true
}
Key.DirectionRight -> {
if (currentIndex < urls.lastIndex) currentIndex++
true
}
Key.S -> {
if (event.isCtrlPressed || event.isMetaPressed) {
triggerSave()
true
} else {
false
}
}
else -> {
false
}
}
}.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (viewMode != ViewMode.FULLSCREEN) onDismiss()
},
) {
// Main content — video or image
if (isVideo) {
Box(
modifier =
contentModifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
// Consume clicks so backdrop dismiss doesn't fire
},
contentAlignment = Alignment.Center,
) {
DesktopVideoPlayer(
url = currentUrl,
autoPlay = true,
initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f,
viewMode = viewMode,
onViewModeChange = { newMode ->
viewMode = newMode
},
modifier =
if (viewMode == ViewMode.DEFAULT) {
Modifier.widthIn(max = 1200.dp)
} else {
Modifier
},
trailingControls = {
MoreOptionsMenu(
menuExpanded = menuExpanded,
onExpandMenu = { menuExpanded = true },
onDismissMenu = { menuExpanded = false },
onSave = { triggerSave() },
onCopyUrl = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(urls[currentIndex]), null)
},
onOpenInBrowser = {
Desktop.getDesktop().browse(URI(urls[currentIndex]))
},
)
},
)
}
} else {
ZoomableImage(
url = currentUrl,
modifier = contentModifier,
)
}
// Download banner (top)
AnimatedVisibility(
visible = downloadState !is DownloadState.Idle,
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter),
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
val state = downloadState
Row(
modifier =
Modifier
.fillMaxWidth()
.background(
when (state) {
is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer
is DownloadState.Done -> Color(0xFF2E7D32)
else -> Color(0xFF1565C0)
},
).padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state) {
is DownloadState.Downloading -> {
Text(
state.filename,
color = Color.White,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
if (state.progress >= 0f) {
LinearProgressIndicator(
progress = { state.progress },
modifier = Modifier.width(120.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
)
} else {
LinearProgressIndicator(
modifier = Modifier.width(120.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
)
}
}
is DownloadState.Done -> {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Text(
"Saved to ${state.file.name}",
color = Color.White,
style = MaterialTheme.typography.bodySmall,
)
}
is DownloadState.Failed -> {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Text(
state.message,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
is DownloadState.Idle -> {}
}
}
}
// "..." menu (bottom right) — only for images; videos get it in the controls bar
// Hidden in fullscreen to keep the view immersive
if (!isVideo && viewMode != ViewMode.FULLSCREEN) {
Box(
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
MoreOptionsMenu(
menuExpanded = menuExpanded,
onExpandMenu = { menuExpanded = true },
onDismissMenu = { menuExpanded = false },
onSave = { triggerSave() },
onCopyUrl = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(urls[currentIndex]), null)
},
onOpenInBrowser = {
Desktop.getDesktop().browse(URI(urls[currentIndex]))
},
)
}
}
// Close button (top-left) — hidden in fullscreen
if (viewMode != ViewMode.FULLSCREEN) {
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
// Image counter (bottom-center) — hidden in fullscreen
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
Text(
text = "${currentIndex + 1} / ${urls.size}",
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
// Navigation arrows — hidden in fullscreen
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
if (currentIndex > 0) {
IconButton(
onClick = { currentIndex-- },
modifier = Modifier.align(Alignment.CenterStart).padding(16.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Previous",
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
}
if (currentIndex < urls.lastIndex) {
IconButton(
onClick = { currentIndex++ },
modifier = Modifier.align(Alignment.CenterEnd).padding(16.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Next",
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
}
}
}
}
@Composable
private fun MoreOptionsMenu(
menuExpanded: Boolean,
onExpandMenu: () -> Unit,
onDismissMenu: () -> Unit,
onSave: () -> Unit,
onCopyUrl: () -> Unit,
onOpenInBrowser: () -> Unit,
) {
Box {
IconButton(onClick = onExpandMenu, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
) {
DropdownMenuItem(
text = { Text("Save") },
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) },
onClick = {
onDismissMenu()
onSave()
},
)
DropdownMenuItem(
text = { Text("Copy URL") },
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
onClick = {
onDismissMenu()
onCopyUrl()
},
)
DropdownMenuItem(
text = { Text("Open in Browser") },
leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) },
onClick = {
onDismissMenu()
onOpenInBrowser()
},
)
}
}
}
@@ -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,
)
}
}
@@ -0,0 +1,270 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
import kotlinx.coroutines.launch
enum class MediaType { AUDIO, VIDEO }
@Composable
fun NowPlayingBar(modifier: Modifier = Modifier) {
val videoState by GlobalMediaPlayer.videoState.collectAsState()
val audioState by GlobalMediaPlayer.audioState.collectAsState()
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
val hasVideo = videoState.url != null
val hasAudio = audioState.url != null
val visible = hasVideo || hasAudio
// Show video bar if video is active, otherwise audio
val activeState = if (hasVideo) videoState else audioState
val activeType = if (hasVideo) MediaType.VIDEO else MediaType.AUDIO
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier,
) {
if (!visible) return@AnimatedVisibility
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Mini video thumbnail or music icon
if (activeType == MediaType.VIDEO && videoFrame != null) {
Image(
bitmap = videoFrame!!,
contentDescription = "Video thumbnail",
modifier =
Modifier
.size(width = 48.dp, height = 36.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
)
} else {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
// Play/pause
IconButton(
onClick = {
if (activeType == MediaType.VIDEO) {
GlobalMediaPlayer.toggleVideoPlayPause()
} else {
GlobalMediaPlayer.toggleAudioPlayPause()
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
if (activeState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (activeState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(20.dp),
)
}
// Current time
Text(
text = formatTime(activeState.currentTime),
style = MaterialTheme.typography.labelSmall,
)
// Seek bar
Slider(
value = activeState.position,
onValueChange = {
if (activeType == MediaType.VIDEO) {
GlobalMediaPlayer.seekVideo(it)
} else {
GlobalMediaPlayer.seekAudio(it)
}
},
modifier = Modifier.weight(1f),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
)
// Duration
Text(
text = formatTime(activeState.duration),
style = MaterialTheme.typography.labelSmall,
)
// URL label (truncated)
Text(
text = activeState.url?.substringAfterLast('/')?.substringBefore('?') ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(120.dp),
)
// Volume / Mute
IconButton(
onClick = {
if (activeType == MediaType.VIDEO) {
GlobalMediaPlayer.toggleVideoMute()
} else {
GlobalMediaPlayer.toggleAudioMute()
}
},
modifier = Modifier.size(24.dp),
) {
Icon(
if (activeState.isMuted) {
Icons.AutoMirrored.Filled.VolumeOff
} else {
Icons.AutoMirrored.Filled.VolumeUp
},
contentDescription = if (activeState.isMuted) "Unmute" else "Mute",
modifier = Modifier.size(16.dp),
)
}
Slider(
value = activeState.volume / 100f,
onValueChange = {
val vol = (it * 100).toInt()
if (activeType == MediaType.VIDEO) {
GlobalMediaPlayer.setVideoVolume(vol)
} else {
GlobalMediaPlayer.setAudioVolume(vol)
}
},
modifier = Modifier.width(80.dp),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
)
// Save button
val scope = rememberCoroutineScope()
IconButton(
onClick = {
activeState.url?.let { url ->
scope.launch {
SaveMediaAction.saveMedia(url = url)
}
}
},
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Save,
contentDescription = "Save",
modifier = Modifier.size(16.dp),
)
}
// Fullscreen (video only)
if (activeType == MediaType.VIDEO) {
IconButton(
onClick = { GlobalMediaPlayer.toggleFullscreen() },
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Fullscreen,
contentDescription = "Fullscreen",
modifier = Modifier.size(16.dp),
)
}
}
Spacer(Modifier.width(4.dp))
// Close/stop
IconButton(
onClick = {
if (activeType == MediaType.VIDEO) {
GlobalMediaPlayer.stopVideo()
} else {
GlobalMediaPlayer.stopAudio()
}
},
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "Stop",
modifier = Modifier.size(16.dp),
)
}
}
}
}
@@ -0,0 +1,131 @@
/*
* 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.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
/**
* Displays a kind 20 picture event (NIP-68) with image-first layout.
*/
@Composable
fun PictureDisplay(
event: PictureEvent,
modifier: Modifier = Modifier,
onImageClick: ((List<String>, Int) -> Unit)? = null,
) {
val imetas = remember(event.id) { event.imetaTags() }
val imageUrls = remember(imetas) { imetas.mapNotNull { it.url } }
val title = remember(event.id) { event.title() }
val description = event.content
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column {
// Images
for ((index, url) in imageUrls.withIndex()) {
val imageModifier =
Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.clip(
if (index == 0 && title == null && description.isBlank()) {
RoundedCornerShape(8.dp)
} else if (index == 0) {
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
} else {
RoundedCornerShape(0.dp)
},
).then(
if (onImageClick != null) {
Modifier.clickable { onImageClick(imageUrls, index) }
} else {
Modifier
},
)
if (isAnimatedGifUrl(url)) {
AnimatedGifImage(
url = url,
contentDescription = title,
modifier = imageModifier,
contentScale = ContentScale.FillWidth,
)
} else {
AsyncImage(
model = url,
contentDescription = title,
modifier = imageModifier,
contentScale = ContentScale.FillWidth,
)
}
}
// Title + description below images
if (title != null || description.isNotBlank()) {
Column(modifier = Modifier.padding(12.dp)) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
}
if (description.isNotBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
@@ -0,0 +1,85 @@
/*
* 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
object SaveMediaAction {
private val httpClient = OkHttpClient()
/**
* Opens a save dialog and downloads the media URL to the chosen file.
* Returns the saved file path or null if cancelled/failed.
*/
suspend fun saveMedia(
url: String,
suggestedFilename: String? = null,
onProgress: ((downloaded: Long, total: Long) -> Unit)? = null,
): File? {
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
// FileDialog must be shown on EDT
val file =
withContext(Dispatchers.Main) {
val dialog =
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
this.file = filename
}
dialog.isVisible = true
val dir = dialog.directory ?: return@withContext null
File(dir, dialog.file ?: return@withContext null)
} ?: return null
// Download on IO
return withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
response.use { resp ->
if (!resp.isSuccessful) return@withContext null
val total = resp.body.contentLength()
resp.body.byteStream().use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(8192)
var downloaded = 0L
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
downloaded += bytesRead
onProgress?.invoke(downloaded, total)
}
}
}
}
file
} catch (_: Exception) {
null
}
}
}
}
@@ -0,0 +1,253 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
@Composable
fun VideoControls(
isPlaying: Boolean,
position: Float,
duration: Long,
currentTime: Long,
onPlayPause: () -> Unit,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
isBuffering: Boolean = false,
volume: Int = 100,
isMuted: Boolean = false,
onVolumeChange: ((Int) -> Unit)? = null,
onMuteToggle: (() -> Unit)? = null,
onFullscreen: (() -> Unit)? = null,
viewMode: ViewMode = ViewMode.DEFAULT,
onViewModeChange: ((ViewMode) -> Unit)? = null,
trailingControls: @Composable (() -> Unit)? = null,
) {
var hovering by remember { mutableStateOf(false) }
Box(
modifier =
modifier
.fillMaxSize()
.clickable { onPlayPause() }
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
when (event.type) {
PointerEventType.Enter -> hovering = true
PointerEventType.Exit -> hovering = false
}
}
}
},
) {
// Center play/buffering indicator
if (isBuffering) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center).size(48.dp),
color = Color.White,
strokeWidth = 3.dp,
)
} else if (!isPlaying) {
// Always show play button when paused
IconButton(
onClick = onPlayPause,
modifier = Modifier.align(Alignment.Center).size(64.dp),
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
}
// Bottom controls — show on hover
AnimatedVisibility(
visible = hovering,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 8.dp),
) {
// Seek slider (full width, no horizontal competition)
Slider(
value = position,
onValueChange = onSeek,
modifier = Modifier.fillMaxWidth(),
colors =
SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.White.copy(alpha = 0.3f),
),
)
// Buttons row
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(onClick = onPlayPause, modifier = Modifier.size(32.dp)) {
Icon(
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
Text(
text = "${formatTime(currentTime)} / ${formatTime(duration)}",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
// Spacer pushes right-side controls to the end
Box(Modifier.weight(1f))
// Volume
if (onMuteToggle != null) {
IconButton(onClick = onMuteToggle, modifier = Modifier.size(32.dp)) {
Icon(
if (isMuted) {
Icons.AutoMirrored.Filled.VolumeOff
} else {
Icons.AutoMirrored.Filled.VolumeUp
},
contentDescription = if (isMuted) "Unmute" else "Mute",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
if (onVolumeChange != null) {
Slider(
value = volume / 100f,
onValueChange = { onVolumeChange((it * 100).toInt()) },
modifier = Modifier.width(240.dp),
colors =
SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White.copy(alpha = 0.7f),
inactiveTrackColor = Color.White.copy(alpha = 0.3f),
),
)
}
// Fullscreen toggle (lightbox) or simple fullscreen (inline)
if (onViewModeChange != null) {
IconButton(
onClick = {
onViewModeChange(
if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN,
)
},
modifier = Modifier.size(32.dp),
) {
Icon(
if (viewMode == ViewMode.FULLSCREEN) {
Icons.Default.FullscreenExit
} else {
Icons.Default.Fullscreen
},
contentDescription =
if (viewMode == ViewMode.FULLSCREEN) "Exit fullscreen" else "Fullscreen",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
} else if (onFullscreen != null) {
IconButton(onClick = onFullscreen, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Fullscreen,
contentDescription = "Fullscreen",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
// Trailing controls (e.g. more-options menu)
trailingControls?.invoke()
}
}
}
}
}
internal fun formatTime(millis: Long): String {
val totalSeconds = millis / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "%d:%02d".format(minutes, seconds)
}
@@ -0,0 +1,117 @@
/*
* 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.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ZoomableImage(
url: String,
modifier: Modifier = Modifier,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier =
modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scale = 1f
offsetX = 0f
offsetY = 0f
},
onTap = {
// Consume single taps so they don't propagate to backdrop
},
)
}.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Scroll) {
val scrollDelta =
event.changes
.firstOrNull()
?.scrollDelta
?.y ?: 0f
val zoomFactor = if (scrollDelta > 0) 0.9f else 1.1f
scale = (scale * zoomFactor).coerceIn(0.5f, 10f)
event.changes.forEach { it.consume() }
}
}
}
}.pointerInput(Unit) {
detectDragGestures { _, dragAmount ->
if (scale > 1f) {
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
},
contentAlignment = Alignment.Center,
) {
val imageModifier =
Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY,
)
if (isAnimatedGifUrl(url)) {
AnimatedGifImage(
url = url,
contentDescription = null,
modifier = imageModifier,
contentScale = ContentScale.Fit,
)
} else {
AsyncImage(
model = url,
contentDescription = null,
modifier = imageModifier,
contentScale = ContentScale.Fit,
)
}
}
}
@@ -22,13 +22,16 @@ package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
@@ -38,16 +41,35 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.Urls
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
/**
* Data class for displaying a note card.
@@ -69,10 +91,66 @@ data class NoteDisplayData(
fun NoteCard(
note: NoteDisplayData,
modifier: Modifier = Modifier,
localCache: DesktopLocalCache? = null,
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
onMentionClick: ((String) -> Unit)? = null,
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
remember(urls) {
urls.withScheme.filter { RichTextParser.isImageUrl(it) }
}
val videoAndAudioUrls =
remember(urls) {
urls.withScheme.filter { RichTextParser.isVideoUrl(it) }
}
val audioUrls =
remember(videoAndAudioUrls) {
videoAndAudioUrls.filter { url ->
val ext =
url
.substringAfterLast('.', "")
.substringBefore('?')
.lowercase()
ext in AUDIO_EXTENSIONS
}
}
val videoUrls =
remember(videoAndAudioUrls, audioUrls) {
videoAndAudioUrls - audioUrls.toSet()
}
val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() }
val strippedContent =
remember(note.content, mediaUrls) {
var text = note.content
for (url in mediaUrls) {
text = text.replace(url, "").trim()
}
text
}
val strippedUrls =
remember(urls, mediaUrls) {
Urls(
withScheme = urls.withScheme - mediaUrls,
withoutScheme = urls.withoutScheme,
emails = urls.emails,
bech32s = urls.bech32s,
relayUrls = urls.relayUrls,
blossomUris = urls.blossomUris,
)
}
// Cap media height to half the window so text is never pushed off-screen
val windowState = LocalWindowState.current
val maxMediaHeight =
if (windowState != null) {
(windowState.size.height * 0.5f).coerceAtLeast(200.dp)
} else {
400.dp
}
Card(
modifier = modifier.fillMaxWidth(),
@@ -80,56 +158,149 @@ fun NoteCard(
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
onClick = onClick ?: {},
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
// Header + text area — clickable to navigate to thread
Column(
modifier =
if (onClick != null) {
Modifier.clickable { onClick() }
} else {
Modifier
},
) {
// Author with avatar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
} else {
Modifier
},
) {
UserAvatar(
userHex = note.pubKeyHex,
pictureUrl = note.profilePictureUrl,
size = 32.dp,
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
)
// Author with avatar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
} else {
Modifier
},
) {
UserAvatar(
userHex = note.pubKeyHex,
pictureUrl = note.profilePictureUrl,
size = 32.dp,
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
)
Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(8.dp))
Text(
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
}
// Timestamp
Text(
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
text = note.createdAt.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Timestamp
Text(
text = note.createdAt.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
if (strippedContent.isNotBlank()) {
RichTextContent(
content = strippedContent,
urls = strippedUrls,
localCache = localCache,
onMentionClick = onMentionClick,
modifier = Modifier.fillMaxWidth(),
)
}
} // end clickable header+text column
// Inline images
if (imageUrls.isNotEmpty()) {
if (strippedContent.isNotBlank()) {
Spacer(Modifier.height(8.dp))
}
for ((index, url) in imageUrls.withIndex()) {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = maxMediaHeight)
.clip(RoundedCornerShape(8.dp))
.then(
if (onImageClick != null) {
Modifier.clickable { onImageClick(imageUrls, index) }
} else {
Modifier
},
),
) {
if (isAnimatedGifUrl(url)) {
AnimatedGifImage(
url = url,
contentDescription = null,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Fit,
)
} else {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Fit,
)
}
}
if (url != imageUrls.last()) {
Spacer(Modifier.height(4.dp))
}
}
}
Spacer(Modifier.height(8.dp))
// Inline videos
if (videoUrls.isNotEmpty()) {
if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
}
for ((index, url) in videoUrls.withIndex()) {
DesktopVideoPlayer(
url = url,
modifier = Modifier.fillMaxWidth().heightIn(max = maxMediaHeight),
onFullscreen =
if (onMediaClick != null) {
{ seekPos -> onMediaClick(videoUrls, index, seekPos) }
} else {
null
},
)
if (url != videoUrls.last()) {
Spacer(Modifier.height(4.dp))
}
}
}
RichTextContent(
content = note.content,
urls = urls,
modifier = Modifier.fillMaxWidth(),
)
// Inline audio
if (audioUrls.isNotEmpty()) {
if (strippedContent.isNotBlank() || imageUrls.isNotEmpty() || videoUrls.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
}
for (url in audioUrls) {
AudioPlayer(
url = url,
modifier = Modifier.fillMaxWidth(),
)
if (url != audioUrls.last()) {
Spacer(Modifier.height(4.dp))
}
}
}
Spacer(Modifier.height(8.dp))
@@ -148,52 +319,152 @@ fun NoteCard(
}
/**
* Renders text content with highlighted URLs.
* Uses RichTextParser from commons to detect and highlight links.
* Resolved bech32 mention with display text and optional pubkey for click navigation.
*/
private data class ResolvedMention(
val displayText: String,
val pubKeyHex: String? = null,
)
/**
* Resolves a nostr: bech32 reference to a display string and optional pubkey.
* For npub/nprofile @displayName + pubkey hex for navigation.
* For note/nevent truncated note ID.
*/
private fun resolveBech32(
bech32: String,
localCache: DesktopLocalCache?,
): ResolvedMention {
val parsed = Nip19Parser.uriToRoute(bech32) ?: return ResolvedMention(bech32)
return when (val entity = parsed.entity) {
is NPub -> {
val user = localCache?.getUserIfExists(entity.hex)
ResolvedMention(
displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}",
pubKeyHex = entity.hex,
)
}
is NProfile -> {
val user = localCache?.getUserIfExists(entity.hex)
ResolvedMention(
displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}",
pubKeyHex = entity.hex,
)
}
is NNote -> {
ResolvedMention("note:${entity.hex.take(8)}...")
}
is NEvent -> {
ResolvedMention("note:${entity.hex.take(8)}...")
}
else -> {
ResolvedMention(bech32.take(24) + "...")
}
}
}
/**
* Extracts pubkey hex strings from all npub/nprofile bech32 references in a set.
* Used to trigger metadata loading for mentioned users.
*/
fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
bech32s.mapNotNull { bech32 ->
val parsed = Nip19Parser.uriToRoute(bech32) ?: return@mapNotNull null
when (val entity = parsed.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
}
/**
* Renders text content with highlighted URLs and clickable nostr: bech32 mentions.
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
* and navigate to profile on click.
*/
@Composable
fun RichTextContent(
content: String,
urls: Urls,
localCache: DesktopLocalCache? = null,
onMentionClick: ((String) -> Unit)? = null,
modifier: Modifier = Modifier,
maxLines: Int = 10,
) {
if (urls.withScheme.isEmpty()) {
val defaultColor = MaterialTheme.colorScheme.onSurface
val primaryColor = MaterialTheme.colorScheme.primary
if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
color = defaultColor,
modifier = modifier,
)
} else {
data class Segment(
val start: Int,
val raw: String,
val isUrl: Boolean,
)
val segments = mutableListOf<Segment>()
for (url in urls.withScheme) {
val idx = content.indexOf(url)
if (idx != -1) segments.add(Segment(idx, url, true))
}
for (bech32 in urls.bech32s) {
val idx = content.indexOf(bech32)
if (idx != -1) segments.add(Segment(idx, bech32, false))
}
segments.sortBy { it.start }
val annotatedText =
buildAnnotatedString {
var lastIndex = 0
// TODO: User the other urls.
val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) }
for (url in sortedUrls) {
val startIndex = content.indexOf(url, lastIndex)
if (startIndex == -1) continue
for (segment in segments) {
if (segment.start < lastIndex) continue
// Add text before URL
if (startIndex > lastIndex) {
append(content.substring(lastIndex, startIndex))
// Add text before segment
if (segment.start > lastIndex) {
append(content.substring(lastIndex, segment.start))
}
// Add URL with styling
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
) {
append(url)
if (segment.isUrl) {
withStyle(
SpanStyle(
color = primaryColor,
textDecoration = TextDecoration.Underline,
),
) {
append(segment.raw)
}
} else {
val resolved = resolveBech32(segment.raw, localCache)
if (resolved.pubKeyHex != null && onMentionClick != null) {
val pubKey = resolved.pubKeyHex
withLink(
LinkAnnotation.Clickable(
tag = "mention",
styles = TextLinkStyles(SpanStyle(color = primaryColor)),
) {
onMentionClick(pubKey)
},
) {
append(resolved.displayText)
}
} else {
withStyle(SpanStyle(color = primaryColor)) {
append(resolved.displayText)
}
}
}
lastIndex = startIndex + url.length
lastIndex = segment.start + segment.raw.length
}
// Add remaining text
@@ -205,9 +476,7 @@ fun RichTextContent(
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
color = defaultColor,
modifier = modifier,
)
}
@@ -0,0 +1,109 @@
/*
* 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.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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 com.vitorpamplona.quartz.nip68Picture.PictureEvent
/**
* Grid gallery view of a user's picture posts (kind 20).
*/
@Composable
fun GalleryTab(
pictureEvents: List<PictureEvent>,
onImageClick: ((List<String>, Int) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
if (pictureEvents.isEmpty()) {
Box(
modifier = modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No pictures yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
modifier = modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(pictureEvents, key = { it.id }) { event ->
val firstImageUrl =
remember(event.id) {
event.imetaTags().firstNotNullOfOrNull { it.url }
}
if (firstImageUrl != null) {
GalleryThumbnail(
url = firstImageUrl,
onClick = {
val allUrls = event.imetaTags().mapNotNull { it.url }
onImageClick?.invoke(allUrls, 0)
},
)
}
}
}
}
@Composable
private fun GalleryThumbnail(
url: String,
onClick: () -> Unit,
) {
AsyncImage(
model = url,
contentDescription = null,
modifier =
Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick),
contentScale = ContentScale.Crop,
)
}
@@ -40,8 +40,6 @@ import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
@@ -61,15 +59,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
import com.vitorpamplona.amethyst.commons.search.KindRegistry
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@Composable
@@ -77,6 +73,7 @@ fun SearchResultsList(
state: AdvancedSearchBarState,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
localCache: DesktopLocalCache? = null,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
@@ -161,14 +158,24 @@ fun SearchResultsList(
if (!collapsed) {
val displayNotes = textNotes.take(5)
items(displayNotes, key = { "note-${it.id}" }) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
if (textNotes.size > 5) {
item(key = "notes-expand") {
ExpandableSection(
remaining = textNotes.drop(5),
) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
NoteCard(
note = event.toNoteDisplayData(localCache),
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
)
}
}
}
@@ -195,14 +202,24 @@ fun SearchResultsList(
}
if (!collapsed) {
items(articles.take(5), key = { "article-${it.id}" }) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
if (articles.size > 5) {
item(key = "articles-expand") {
ExpandableSection(
remaining = articles.drop(5),
) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
NoteCard(
note = event.toNoteDisplayData(localCache),
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
)
}
}
}
@@ -227,7 +244,13 @@ fun SearchResultsList(
}
if (!collapsed) {
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
}
}
@@ -303,58 +326,6 @@ private fun SortableHeader(
}
}
@Composable
private fun NotePreviewCard(
event: Event,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Kind badge
val kindName = KindRegistry.nameFor(event.kind) ?: "kind ${event.kind}"
Text(
kindName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
// Author (hex truncated)
Text(
event.pubKey.take(8) + "..." + event.pubKey.takeLast(4),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.weight(1f))
// Timestamp
Text(
event.createdAt.toTimeAgo(withDot = false),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
// Content preview
Text(
event.content.take(200),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun <T> ExpandableSection(
remaining: List<T>,
@@ -0,0 +1,313 @@
/*
* 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.settings
import androidx.compose.foundation.clickable
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.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun MediaServerSettings(
initialServers: List<String> = emptyList(),
onServersChanged: (List<String>) -> Unit = {},
modifier: Modifier = Modifier,
) {
val servers = remember { mutableStateListOf<String>().apply { addAll(initialServers) } }
val serverStatuses = remember { mutableStateMapOf<String, ServerHealthCheck.ServerStatus>() }
var newServerUrl by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
var isChecking by remember { mutableStateOf(false) }
// Check health on first load (parallel)
LaunchedEffect(servers.toList()) {
coroutineScope {
for (server in servers) {
if (server !in serverStatuses) {
launch {
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
}
}
}
}
Column(modifier = modifier.fillMaxWidth().padding(16.dp)) {
Text(
"Media Servers (Blossom)",
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(8.dp))
Text(
"Configure Blossom servers for media uploads. First server is the default.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
// Server list
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
for (server in servers.toList()) {
ServerRow(
server = server,
status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN,
isDefault = servers.indexOf(server) == 0,
onSetDefault = {
servers.remove(server)
servers.add(0, server)
onServersChanged(servers.toList())
},
onRemove = {
servers.remove(server)
serverStatuses.remove(server)
onServersChanged(servers.toList())
},
onRefresh = {
scope.launch {
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
},
)
}
}
Spacer(Modifier.height(16.dp))
// Add server
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = newServerUrl,
onValueChange = { newServerUrl = it },
label = { Text("Server URL") },
placeholder = { Text("https://blossom.example.com") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val url = newServerUrl.trim().removeSuffix("/")
if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) {
servers.add(url)
newServerUrl = ""
onServersChanged(servers.toList())
scope.launch {
val status = ServerHealthCheck.check(url)
serverStatuses[url] = status
}
}
},
enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()),
) {
Icon(Icons.Default.Add, contentDescription = "Add")
Spacer(Modifier.width(4.dp))
Text("Add")
}
}
Spacer(Modifier.height(8.dp))
// Refresh all
Button(
onClick = {
scope.launch {
isChecking = true
coroutineScope {
for (server in servers) {
launch {
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
}
}
isChecking = false
}
},
enabled = !isChecking,
) {
if (isChecking) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
Spacer(Modifier.width(4.dp))
Text("Check All")
}
}
}
private fun isValidServerUrl(url: String): Boolean {
val trimmed = url.trim().removeSuffix("/")
return try {
val uri = java.net.URI(trimmed)
uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".")
} catch (_: Exception) {
false
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ServerRow(
server: String,
status: ServerHealthCheck.ServerStatus,
isDefault: Boolean,
onSetDefault: () -> Unit,
onRemove: () -> Unit,
onRefresh: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Status indicator with tooltip
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
when (status) {
ServerHealthCheck.ServerStatus.ONLINE -> "Online"
ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable"
ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..."
},
)
}
},
state = rememberTooltipState(),
) {
Surface(
modifier = Modifier.size(12.dp),
shape = CircleShape,
color =
when (status) {
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
},
) {}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
server,
style = MaterialTheme.typography.bodyMedium,
)
if (isDefault) {
Text(
"Default server",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
"Set as default",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { onSetDefault() },
)
}
}
IconButton(onClick = onRefresh) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
modifier = Modifier.size(18.dp),
)
}
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -0,0 +1,116 @@
/*
* 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.service.media
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests for the AESGCM encryption used by EncryptedMediaService.
* These test the crypto primitives without requiring network access.
*/
class EncryptedMediaServiceTest {
@Test
fun aesgcmEncryptDecryptRoundTrip() {
val plaintext = "Hello, encrypted media!".toByteArray()
val cipher = AESGCM()
val encrypted = cipher.encrypt(plaintext)
val decrypted = cipher.decrypt(encrypted)
assertContentEquals(plaintext, decrypted)
}
@Test
fun aesgcmEncryptedDataDiffersFromPlaintext() {
val plaintext = "Secret data".toByteArray()
val cipher = AESGCM()
val encrypted = cipher.encrypt(plaintext)
assertFalse(plaintext.contentEquals(encrypted))
assertTrue(encrypted.size > plaintext.size) // Includes auth tag
}
@Test
fun aesgcmDecryptWithExplicitKeyAndNonce() {
val cipher1 = AESGCM()
val plaintext = "Roundtrip test data".toByteArray()
val encrypted = cipher1.encrypt(plaintext)
// Reconstruct cipher with same key and nonce
val cipher2 = AESGCM(cipher1.keyBytes, cipher1.nonce)
val decrypted = cipher2.decrypt(encrypted)
assertContentEquals(plaintext, decrypted)
}
@Test
fun aesgcmKeyAndNonceAreGenerated() {
val cipher = AESGCM()
assertNotNull(cipher.keyBytes)
assertNotNull(cipher.nonce)
assertTrue(cipher.keyBytes.size == 32) // AES-256
assertTrue(cipher.nonce.size == 16)
}
@Test
fun aesgcmDifferentCiphersProduceDifferentOutput() {
val plaintext = "Same message".toByteArray()
val cipher1 = AESGCM()
val cipher2 = AESGCM()
val encrypted1 = cipher1.encrypt(plaintext)
val encrypted2 = cipher2.encrypt(plaintext)
// Different keys should produce different ciphertext
assertFalse(encrypted1.contentEquals(encrypted2))
}
@Test
fun aesgcmHandlesEmptyData() {
val cipher = AESGCM()
val plaintext = byteArrayOf()
val encrypted = cipher.encrypt(plaintext)
val decrypted = cipher.decrypt(encrypted)
assertContentEquals(plaintext, decrypted)
}
@Test
fun aesgcmHandlesLargeData() {
val cipher = AESGCM()
// Simulate a small "file" - 64KB
val plaintext = ByteArray(65536) { (it % 256).toByte() }
val encrypted = cipher.encrypt(plaintext)
val decrypted = cipher.decrypt(encrypted)
assertContentEquals(plaintext, decrypted)
}
}
@@ -0,0 +1,49 @@
/*
* 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.service.media
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ServerHealthCheckTest {
@Test
fun checkOfflineForInvalidUrl() =
runTest {
// Localhost on a random high port should be unreachable
val status = ServerHealthCheck.check("http://127.0.0.1:19999")
assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status)
}
@Test
fun checkOfflineForMalformedUrl() =
runTest {
val status = ServerHealthCheck.check("not-a-url")
assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status)
}
@Test
fun checkOfflineForNonexistentHost() =
runTest {
val status = ServerHealthCheck.check("https://this-host-definitely-does-not-exist-12345.example.com")
assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status)
}
}
@@ -0,0 +1,221 @@
/*
* 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.service.upload
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import okhttp3.Call
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class DesktopBlossomClientTest {
private fun mockOkHttp(
responseCode: Int,
body: String = "",
headers: Headers = Headers.headersOf(),
): OkHttpClient {
val requestSlot = slot<Request>()
val mockCall = mockk<Call>()
val mockClient = mockk<OkHttpClient>()
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
every { mockCall.execute() } returns
Response
.Builder()
.request(Request.Builder().url("https://example.com").build())
.protocol(Protocol.HTTP_1_1)
.code(responseCode)
.message(if (responseCode == 200) "OK" else "Error")
.headers(headers)
.body(body.toResponseBody())
.build()
return mockClient
}
@Test
fun uploadSuccessReturnsResult() =
runTest {
val json =
"""{"url":"https://blossom.example.com/abc123.png","sha256":"abc123","size":1024}"""
val client = DesktopBlossomClient(mockOkHttp(200, json))
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1, 2, 3))
try {
val result =
client.upload(
file = file,
contentType = "image/png",
serverBaseUrl = "https://blossom.example.com",
authHeader = "Nostr abc",
)
assertEquals("https://blossom.example.com/abc123.png", result.url)
assertEquals("abc123", result.sha256)
assertEquals(1024L, result.size)
} finally {
file.delete()
}
}
@Test
fun uploadFailureThrowsException() =
runTest {
val headers = Headers.headersOf("X-Reason", "File too large")
val client = DesktopBlossomClient(mockOkHttp(413, "", headers))
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1, 2, 3))
try {
val ex =
assertFailsWith<RuntimeException> {
client.upload(
file = file,
contentType = "image/png",
serverBaseUrl = "https://blossom.example.com",
authHeader = null,
)
}
assertTrue(ex.message!!.contains("File too large"))
} finally {
file.delete()
}
}
@Test
fun uploadFailureUsesStatusCodeWhenNoXReason() =
runTest {
val client = DesktopBlossomClient(mockOkHttp(500))
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1))
try {
val ex =
assertFailsWith<RuntimeException> {
client.upload(
file = file,
contentType = "image/png",
serverBaseUrl = "https://blossom.example.com",
authHeader = null,
)
}
assertTrue(ex.message!!.contains("500"))
} finally {
file.delete()
}
}
@Test
fun uploadSendsAuthorizationHeader() =
runTest {
val requestSlot = slot<Request>()
val mockCall = mockk<Call>()
val mockClient = mockk<OkHttpClient>()
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
every { mockCall.execute() } returns
Response
.Builder()
.request(Request.Builder().url("https://example.com").build())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
.build()
val client = DesktopBlossomClient(mockClient)
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1))
try {
client.upload(
file = file,
contentType = "image/png",
serverBaseUrl = "https://blossom.example.com",
authHeader = "Nostr base64token",
)
val sentRequest = requestSlot.captured
assertEquals("Nostr base64token", sentRequest.header("Authorization"))
assertEquals("https://blossom.example.com/upload", sentRequest.url.toString())
assertEquals("PUT", sentRequest.method)
} finally {
file.delete()
}
}
@Test
fun uploadUrlStripsTrailingSlash() =
runTest {
val requestSlot = slot<Request>()
val mockCall = mockk<Call>()
val mockClient = mockk<OkHttpClient>()
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
every { mockCall.execute() } returns
Response
.Builder()
.request(Request.Builder().url("https://example.com").build())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
.build()
val client = DesktopBlossomClient(mockClient)
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1))
try {
client.upload(
file = file,
contentType = "image/png",
serverBaseUrl = "https://blossom.example.com/",
authHeader = null,
)
// Should not have double slash
assertEquals("https://blossom.example.com/upload", requestSlot.captured.url.toString())
} finally {
file.delete()
}
}
}
@@ -0,0 +1,124 @@
/*
* 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.service.upload
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopMediaCompressorTest {
@Test
fun stripExifReturnsSameFileForPng() {
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "png", file)
val result = DesktopMediaCompressor.stripExif(file)
// Should return the same file object since it's not JPEG
assertEquals(file, result)
file.delete()
}
@Test
fun stripExifReturnsSameFileForTextFile() {
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
file.writeText("not a jpeg")
val result = DesktopMediaCompressor.stripExif(file)
assertEquals(file, result)
file.delete()
}
@Test
fun stripExifReturnsSameFileForMp4() {
val file = File.createTempFile("test_", ".mp4")
file.deleteOnExit()
file.writeBytes(byteArrayOf(0, 0, 0))
val result = DesktopMediaCompressor.stripExif(file)
assertEquals(file, result)
file.delete()
}
@Test
fun stripExifHandlesJpegWithoutExif() {
// Create a minimal JPEG without EXIF
val file = createMinimalJpeg()
try {
val result = DesktopMediaCompressor.stripExif(file)
// Should return the same file since there's no EXIF to strip
assertEquals(file, result)
} finally {
file.delete()
}
}
@Test
fun stripExifProcessesJpegFile() {
// Create a JPEG (may or may not have metadata depending on ImageIO)
val file = File.createTempFile("test_", ".jpg")
file.deleteOnExit()
val img = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "jpg", file)
val result = DesktopMediaCompressor.stripExif(file)
// Result should be a valid file regardless
assertTrue(result.exists())
assertTrue(result.length() > 0)
// Clean up temp file if different from original
if (result != file) {
result.delete()
}
file.delete()
}
@Test
fun stripExifHandlesUppercaseJpeg() {
val file = File.createTempFile("test_", ".JPEG")
file.deleteOnExit()
val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "jpg", file)
val result = DesktopMediaCompressor.stripExif(file)
assertTrue(result.exists())
if (result != file) result.delete()
file.delete()
}
private fun createMinimalJpeg(): File {
val file = File.createTempFile("minimal_", ".jpg")
file.deleteOnExit()
val img = BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "jpg", file)
return file
}
}
@@ -0,0 +1,172 @@
/*
* 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.service.upload
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopMediaMetadataTest {
// --- guessMimeType ---
@Test
fun guessMimeTypeForJpeg() {
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpg")))
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpeg")))
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.JPEG")))
}
@Test
fun guessMimeTypeForPng() {
assertEquals("image/png", DesktopMediaMetadata.guessMimeType(File("image.png")))
}
@Test
fun guessMimeTypeForGif() {
assertEquals("image/gif", DesktopMediaMetadata.guessMimeType(File("anim.gif")))
}
@Test
fun guessMimeTypeForWebp() {
assertEquals("image/webp", DesktopMediaMetadata.guessMimeType(File("image.webp")))
}
@Test
fun guessMimeTypeForSvg() {
assertEquals("image/svg+xml", DesktopMediaMetadata.guessMimeType(File("icon.svg")))
}
@Test
fun guessMimeTypeForAvif() {
assertEquals("image/avif", DesktopMediaMetadata.guessMimeType(File("photo.avif")))
}
@Test
fun guessMimeTypeForVideoFormats() {
assertEquals("video/mp4", DesktopMediaMetadata.guessMimeType(File("clip.mp4")))
assertEquals("video/webm", DesktopMediaMetadata.guessMimeType(File("clip.webm")))
assertEquals("video/quicktime", DesktopMediaMetadata.guessMimeType(File("clip.mov")))
}
@Test
fun guessMimeTypeForAudioFormats() {
assertEquals("audio/mpeg", DesktopMediaMetadata.guessMimeType(File("song.mp3")))
assertEquals("audio/ogg", DesktopMediaMetadata.guessMimeType(File("track.ogg")))
assertEquals("audio/wav", DesktopMediaMetadata.guessMimeType(File("sound.wav")))
assertEquals("audio/flac", DesktopMediaMetadata.guessMimeType(File("lossless.flac")))
}
@Test
fun guessMimeTypeForUnknownExtension() {
assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("data.xyz")))
assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("noext")))
}
// --- compute ---
@Test
fun computeForPngImage() {
val file = createTempPng(width = 10, height = 5)
try {
val meta = DesktopMediaMetadata.compute(file)
assertEquals("image/png", meta.mimeType)
assertTrue(meta.size > 0)
assertTrue(meta.sha256.length == 64) // hex-encoded SHA-256
assertEquals(10, meta.width)
assertEquals(5, meta.height)
assertNotNull(meta.blurhash)
} finally {
file.delete()
}
}
@Test
fun computeForTextFile() {
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
file.writeText("hello world")
try {
val meta = DesktopMediaMetadata.compute(file)
assertEquals("application/octet-stream", meta.mimeType)
assertEquals(11L, meta.size)
assertTrue(meta.sha256.isNotEmpty())
assertNull(meta.width)
assertNull(meta.height)
assertNull(meta.blurhash)
} finally {
file.delete()
}
}
@Test
fun computeProducesConsistentHash() {
val file = File.createTempFile("hash_", ".txt")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1, 2, 3, 4, 5))
try {
val meta1 = DesktopMediaMetadata.compute(file)
val meta2 = DesktopMediaMetadata.compute(file)
assertEquals(meta1.sha256, meta2.sha256)
} finally {
file.delete()
}
}
@Test
fun computeForMp4GivesNoDimensions() {
val file = File.createTempFile("video_", ".mp4")
file.deleteOnExit()
file.writeBytes(byteArrayOf(0, 0, 0))
try {
val meta = DesktopMediaMetadata.compute(file)
assertEquals("video/mp4", meta.mimeType)
assertNull(meta.width)
assertNull(meta.height)
assertNull(meta.blurhash)
} finally {
file.delete()
}
}
private fun createTempPng(
width: Int,
height: Int,
): File {
val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
// Draw something so blurhash has data
val g = img.createGraphics()
g.color = java.awt.Color.BLUE
g.fillRect(0, 0, width, height)
g.dispose()
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
ImageIO.write(img, "png", file)
return file
}
}
@@ -0,0 +1,222 @@
/*
* 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.service.upload
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class DesktopUploadOrchestratorTest {
@BeforeTest
fun setup() {
mockkObject(DesktopBlossomAuth)
coEvery {
DesktopBlossomAuth.createUploadAuth(any(), any(), any(), any())
} returns "Nostr fakeAuthToken"
}
@AfterTest
fun teardown() {
unmockkObject(DesktopBlossomAuth)
}
@Test
fun uploadCallsClientWithCorrectParameters() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val fileSlot = slot<File>()
val contentTypeSlot = slot<String>()
val urlSlot = slot<String>()
coEvery {
mockClient.upload(
file = capture(fileSlot),
contentType = capture(contentTypeSlot),
serverBaseUrl = capture(urlSlot),
authHeader = any(),
)
} returns
BlossomUploadResult(
url = "https://blossom.example.com/abc123.png",
sha256 = "abc123",
size = 100,
)
val orchestrator = DesktopUploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
val img = java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB)
javax.imageio.ImageIO.write(img, "png", file)
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
try {
val result =
orchestrator.upload(
file = file,
alt = "test upload",
serverBaseUrl = "https://blossom.example.com",
signer = mockSigner,
stripExif = false,
)
coVerify(exactly = 1) {
mockClient.upload(
file = any(),
contentType = any(),
serverBaseUrl = any(),
authHeader = any(),
)
}
assertEquals("https://blossom.example.com", urlSlot.captured)
assertEquals("image/png", contentTypeSlot.captured)
assertNotNull(result.metadata)
assertEquals("image/png", result.metadata.mimeType)
} finally {
file.delete()
}
}
@Test
fun uploadPassesSameFileWhenNoStripExif() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val fileSlot = slot<File>()
coEvery {
mockClient.upload(
file = capture(fileSlot),
contentType = any(),
serverBaseUrl = any(),
authHeader = any(),
)
} returns BlossomUploadResult(url = "https://example.com/hash")
val orchestrator = DesktopUploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
file.writeText("content")
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
try {
orchestrator.upload(
file = file,
alt = null,
serverBaseUrl = "https://example.com",
signer = mockSigner,
stripExif = false,
)
assertEquals(file.absolutePath, fileSlot.captured.absolutePath)
} finally {
file.delete()
}
}
@Test
fun uploadComputesMetadata() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
coEvery {
mockClient.upload(any<java.io.File>(), any<String>(), any<String>(), any())
} returns BlossomUploadResult(url = "https://example.com/hash")
val orchestrator = DesktopUploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
file.writeText("hello world")
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
try {
val result =
orchestrator.upload(
file = file,
alt = null,
serverBaseUrl = "https://example.com",
signer = mockSigner,
stripExif = false,
)
assertEquals(11L, result.metadata.size)
assertTrue(result.metadata.sha256.length == 64)
assertEquals("application/octet-stream", result.metadata.mimeType)
} finally {
file.delete()
}
}
@Test
fun uploadPassesAuthHeaderToClient() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val authSlot = slot<String?>()
coEvery {
mockClient.upload(
file = any(),
contentType = any(),
serverBaseUrl = any(),
authHeader = captureNullable(authSlot),
)
} returns BlossomUploadResult(url = "https://example.com/hash")
val orchestrator = DesktopUploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
file.writeText("data")
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
try {
orchestrator.upload(
file = file,
alt = null,
serverBaseUrl = "https://example.com",
signer = mockSigner,
stripExif = false,
)
assertEquals("Nostr fakeAuthToken", authSlot.captured)
} finally {
file.delete()
}
}
}
@@ -0,0 +1,127 @@
/*
* 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.service.upload
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopUploadTrackerTest {
@Test
fun initialStateIsIdle() {
val tracker = DesktopUploadTracker()
val state = tracker.state.value
assertFalse(state.isUploading)
assertNull(state.fileName)
assertNull(state.error)
assertNull(state.result)
}
@Test
fun startUploadSetsUploadingState() {
val tracker = DesktopUploadTracker()
tracker.startUpload("photo.jpg")
val state = tracker.state.value
assertTrue(state.isUploading)
assertEquals("photo.jpg", state.fileName)
assertNull(state.error)
assertNull(state.result)
}
@Test
fun onSuccessStoresResult() {
val tracker = DesktopUploadTracker()
val metadata = MediaMetadata(sha256 = "abc123", size = 1024, mimeType = "image/png")
val blossom = BlossomUploadResult(url = "https://blossom.example.com/abc123.png")
val result = UploadResult(blossom = blossom, metadata = metadata)
tracker.startUpload("test.png")
tracker.onSuccess(result)
val state = tracker.state.value
assertFalse(state.isUploading)
assertNotNull(state.result)
assertEquals("https://blossom.example.com/abc123.png", state.result!!.blossom.url)
assertNull(state.error)
}
@Test
fun onErrorStoresErrorMessage() {
val tracker = DesktopUploadTracker()
tracker.startUpload("test.png")
tracker.onError("Connection refused")
val state = tracker.state.value
assertFalse(state.isUploading)
assertEquals("Connection refused", state.error)
assertNull(state.result)
}
@Test
fun resetReturnsToInitialState() {
val tracker = DesktopUploadTracker()
tracker.startUpload("test.png")
tracker.onError("failed")
tracker.reset()
val state = tracker.state.value
assertFalse(state.isUploading)
assertNull(state.fileName)
assertNull(state.error)
assertNull(state.result)
}
@Test
fun stateFlowEmitsLatestValue() =
runTest {
val tracker = DesktopUploadTracker()
// Initial emission
val initial = tracker.state.first()
assertFalse(initial.isUploading)
tracker.startUpload("file.mp4")
val uploading = tracker.state.first()
assertTrue(uploading.isUploading)
assertEquals("file.mp4", uploading.fileName)
}
@Test
fun multipleUploadsOverwriteState() {
val tracker = DesktopUploadTracker()
tracker.startUpload("first.jpg")
tracker.startUpload("second.png")
assertEquals("second.png", tracker.state.value.fileName)
}
}
+14
View File
@@ -0,0 +1,14 @@
# TODO
## DM: Mixed NIP-04 + NIP-17 conversations
**Question:** What happens when a conversation with an npub has both NIP-04 (kind 4) and NIP-17 (kind 14/15 in GiftWrap) messages?
**Current behavior to investigate:**
- NIP-04 messages use `PrivateDmEvent.chatroomKey(pubKey)` → creates a `ChatroomKey` based on recipient
- NIP-17 messages use `ChatMessageEvent.chatroomKey(pubKey)` → also creates a `ChatroomKey` based on group members
- Do these produce the **same** `ChatroomKey` for 1-on-1 chats? If yes, both message types merge into one conversation. If no, they appear as separate conversations.
- Android Amethyst handles this — check how `Account.kt` merges them
- Edge cases: timeline ordering, decryption display, NIP-04 messages showing as "legacy" vs NIP-17
**Action:** Test with a real conversation that has both types. If they split into two rooms, we need to merge them in `ChatroomListState` or unify the `ChatroomKey` derivation.
@@ -0,0 +1,604 @@
# Blossom Protocol Research
**Date**: 2026-03-16
**Sources**: hzrd149/blossom GitHub (BUD specs), NIP-B7, Nostrify docs, Amethyst upstream codebase, Primal blog posts
---
## Overview
Blossom (**Bl**obs **O**n **S**imple **S**erver**om**... or something) is a specification for HTTP endpoints that let users store binary blobs on publicly accessible servers. Blobs are content-addressed by their **SHA-256 hash**. Uses Nostr keypairs for identity and authorization.
**Two Nostr event kinds:**
- **Kind 24242** -- Authorization token (BUD-11)
- **Kind 10063** -- User's Blossom server list (BUD-03, NIP-B7)
**BUD index (BUD-00 through BUD-11):**
| BUD | Name | Status | Required |
|-----|------|--------|----------|
| 00 | BUD framework | - | - |
| 01 | Server requirements + blob retrieval | draft | mandatory |
| 02 | Upload + management | draft | optional |
| 03 | User server list (kind 10063) | draft | optional |
| 04 | Mirroring | draft | optional |
| 05 | Media optimization | draft | optional |
| 06 | Upload requirements (HEAD preflight) | draft | optional |
| 07 | Payment required (402) | draft | optional |
| 08 | NIP-94 file metadata tags | draft | optional |
| 09 | Blob report | draft | optional |
| 10 | Blossom URI scheme | draft | optional |
| 11 | Nostr authorization | draft | optional |
---
## BUD-01: Server Requirements + Blob Retrieval
**Status:** `draft` `mandatory`
### CORS
All responses MUST set `Access-Control-Allow-Origin: *`.
Preflight (`OPTIONS`) responses MUST also set:
```
Access-Control-Allow-Headers: Authorization, *
Access-Control-Allow-Methods: GET, HEAD, PUT, DELETE
```
MAY set `Access-Control-Max-Age: 86400` (cache 24h).
### Error Responses
Any 4xx/5xx response MAY include `X-Reason` header with human-readable error message.
### Endpoints
All endpoints served from domain root. No path prefix.
#### GET /<sha256> -- Get Blob
```http
GET /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
Host: cdn.example.com
```
Response:
```http
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 184292
<binary blob data>
```
- MUST accept optional file extension in URL (`.pdf`, `.png`, etc.)
- MUST return correct `Content-Type` regardless of extension
- MUST default to `application/octet-stream` if MIME unknown
- MAY require authorization (BUD-11)
**Proxying/Redirection:**
- 3xx redirects MUST redirect to URL containing same SHA-256 hash
- Destination MUST set `Access-Control-Allow-Origin: *`, `Content-Type`, `Content-Length`
**Range Requests:**
- Servers SHOULD support `Range` header (RFC 7233) on GET
- Signal via `Accept-Ranges: bytes` and `Content-Length` on HEAD
#### HEAD /<sha256> -- Has Blob
Identical to GET but MUST NOT return body. MUST return same `Content-Type` and `Content-Length` headers.
---
## BUD-02: Upload + Management
**Status:** `draft` `optional`
### Blob Descriptor
The standard JSON response for all upload/mirror operations:
```json
{
"url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf",
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"size": 184292,
"type": "application/pdf",
"uploaded": 1725105921
}
```
Fields:
- `url` -- Public URL to `GET /<sha256>` endpoint **with file extension**
- `sha256` -- Hex-encoded SHA-256 of the blob
- `size` -- Size in bytes
- `type` -- MIME type (fallback `application/octet-stream`)
- `uploaded` -- Unix timestamp
MAY include: `magnet`, `infohash`, `ipfs`
### PUT /upload -- Upload Blob
```http
PUT /upload HTTP/1.1
Host: cdn.example.com
Authorization: Nostr <base64url-encoded kind 24242 event>
Content-Type: image/png
Content-Length: 184292
X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553
<raw binary data>
```
Response (success):
```http
HTTP/1.1 200 OK
Content-Type: application/json
{
"url": "https://cdn.example.com/b167...553.png",
"sha256": "b167...553",
"size": 184292,
"type": "image/png",
"uploaded": 1725105921
}
```
Key rules:
- Server MUST NOT modify the blob
- Server MUST compute SHA-256 over exact bytes received
- Client SHOULD include `Content-Type` and `Content-Length`
- Client MAY provide `X-SHA-256` header (hex lowercase)
- Server MAY use `X-SHA-256` for pre-upload rejection policies
- Success: 2xx with Blob Descriptor
- Failure: 4xx with error message
### GET /list/<pubkey> -- List Blobs (Unrecommended)
Optional. Returns JSON array of Blob Descriptors for a pubkey.
Query params:
- `cursor` -- SHA-256 of last blob (cursor-based pagination)
- `limit` -- Max results
- `since`/`until` -- Filter by upload date (deprecated for pagination)
Sorted by `uploaded` descending.
### DELETE /<sha256> -- Delete Blob
```http
DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
Host: cdn.example.com
Authorization: Nostr <base64url-encoded kind 24242 event>
```
- Multiple `x` tags in auth token MUST NOT be interpreted as batch delete
---
## BUD-03: User Server List
**Kind 10063** (replaceable event).
```json
{
"kind": 10063,
"tags": [
["server", "https://cdn.self.hosted"],
["server", "https://cdn.satellite.earth"],
["alt", "File servers used by the author"]
],
"content": ""
}
```
- Tag order = priority. Most trusted/reliable first.
- Clients MUST upload to at least the first server in user's list.
- Clients MAY mirror to other listed servers via BUD-04.
**Discovery flow when URL breaks:**
1. Extract 64-char hex hash from broken URL
2. Fetch author's kind:10063 event
3. Try each listed server in order
4. Fall back to well-known servers
---
## BUD-04: Mirroring
**Status:** `draft` `optional`
### PUT /mirror -- Mirror Blob
```http
PUT /mirror HTTP/1.1
Host: backup-server.example.com
Authorization: Nostr <base64url-encoded kind 24242 event>
Content-Type: application/json
{
"url": "https://cdn.satellite.earth/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
}
```
Response: Blob Descriptor (same as upload).
Key rules:
- Server downloads blob from provided URL
- Server SHOULD use `Content-Type` from origin server
- Server verifies downloaded blob hash matches `x` tag in auth token
- Returns 2xx + Blob Descriptor on success, 4xx on failure
**Typical flow:**
1. Client uploads to Server A, gets Blob Descriptor with URL
2. Client sends URL to Server B's `/mirror` with same upload auth token
3. Server B downloads from Server A
4. Server B verifies hash matches `x` tag
5. Server B returns Blob Descriptor
---
## BUD-05: Media Optimization
**Status:** `draft` `optional`
### PUT /media -- Optimized Upload
```http
PUT /media HTTP/1.1
Host: trusted-server.example.com
Authorization: Nostr <base64url-encoded kind 24242 event with t=media>
Content-Type: image/png
Content-Length: 4194304
<raw binary data>
```
Response: Blob Descriptor -- but hash will differ from input because server transforms the file.
Key differences from `/upload`:
- Server MAY modify/optimize the blob (strip EXIF, compress, transcode)
- The returned SHA-256 will be of the **optimized** blob, not the original
- Client has NO control over optimization process
- `t` tag in auth event must be `media` (not `upload`)
### HEAD /media
Same as HEAD /upload (BUD-06) but for the media endpoint.
### Client Implementation Pattern
1. User selects a "trusted processing" server
2. Client uploads original media to `/media` on trusted server
3. Gets back optimized blob descriptor (new hash)
4. Client signs new upload auth for the optimized hash
5. Calls `/mirror` on other servers to distribute the optimized blob
This is what Primal does -- all Primal 2.2+ apps use `/media` by default, strips metadata, then mirrors.
---
## BUD-06: Upload Requirements (HEAD Preflight)
**Status:** `draft` `optional`
### HEAD /upload -- Pre-flight Check
Client sends blob metadata, server says yes/no before actual upload.
Request:
```http
HEAD /upload HTTP/1.1
Host: cdn.example.com
X-Content-Type: application/pdf
X-Content-Length: 184292
X-SHA-256: 88a74d0b866c8ba79251a11fe5ac807839226870e77355f02eaf68b156522576
Authorization: Nostr <base64url-encoded event>
```
Success:
```http
HTTP/1.1 200 OK
```
Failure examples:
```http
HTTP/1.1 400 Bad Request
X-Reason: Invalid X-SHA-256 header format. Expected a string.
HTTP/1.1 401 Unauthorized
X-Reason: Authorization required for uploading video files.
HTTP/1.1 403 Forbidden
X-Reason: SHA-256 hash banned.
HTTP/1.1 411 Length Required
X-Reason: Missing X-Content-Length header.
HTTP/1.1 413 Content Too Large
X-Reason: File too large. Max allowed size is 100MB.
HTTP/1.1 415 Unsupported Media Type
X-Reason: Unsupported file type.
```
**Note:** Uses `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers (not standard `Content-*`).
---
## BUD-07: Payment Required
Servers MAY return `402 Payment Required` with payment method headers:
```http
HTTP/1.1 402 Payment Required
X-Cashu: "<NUT-24 cashu token>"
X-Lightning: "<BOLT-11 invoice>"
```
After payment, client retries with proof:
- Cashu: serialized `cashuB` token per NUT-24
- Lightning: preimage of the BOLT-11 payment
HEAD requests inform about cost but should not be retried with payment; proceed to PUT/GET after paying.
---
## BUD-08: NIP-94 File Metadata Tags
Servers MAY include a `nip94` field in Blob Descriptor responses:
```json
{
"url": "https://cdn.example.com/b167...553.pdf",
"sha256": "b167...553",
"size": 184292,
"type": "application/pdf",
"uploaded": 1725105921,
"nip94": [
["url", "https://cdn.example.com/b167...553.pdf"],
["m", "application/pdf"],
["x", "b167...553"],
["size", "184292"],
["magnet", "magnet:?xt=urn:btih:..."],
["i", "infohash-here"]
]
}
```
Follows NIP-94 tag format as KV pairs. Allows clients to get standardized metadata without separate requests.
---
## BUD-09: Blob Report
### PUT /report
Body: signed NIP-56 report event (kind 1984):
```json
{
"kind": 1984,
"content": "This blob contains illegal content",
"tags": [
["x", "<sha256>", "illegal"],
["p", "<uploader-pubkey>"]
]
}
```
Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal.
---
## BUD-10: Blossom URI Scheme
Format:
```
blossom:<sha256>.<ext>[?param1=value1&param2=value2...]
```
Example:
```
blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=<author-hex-pubkey>&sz=184292
```
Query parameters:
- `xs` -- Server domain hints (tried first). Repeatable.
- `as` -- Author hex pubkey for BUD-03 server list lookup. Repeatable.
- `sz` -- Size in bytes.
**Resolution priority:**
1. Direct server hints (`xs`) via `GET /<sha256>`
2. Author server lists (fetch kind:10063 for each `as` pubkey)
3. Fallback to well-known servers or local cache
---
## BUD-11: Authorization
### Kind 24242 Event Structure
```json
{
"id": "<event-id>",
"pubkey": "<hex-pubkey>",
"created_at": 1725105921,
"kind": 24242,
"tags": [
["t", "upload"],
["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"],
["expiration", "1725109521"],
["server", "cdn.example.com"]
],
"content": "Upload cat photo",
"sig": "<schnorr-signature>"
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `t` | Action verb: `get`, `upload`, `list`, `delete`, `media` |
| `expiration` | Unix timestamp when token expires (NIP-40) |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `x` | SHA-256 hash of specific blob. Multiple allowed. |
| `server` | Domain restriction (lowercase). Multiple allowed. |
| `size` | File size in bytes (Amethyst adds this) |
### Authorization Header
```
Authorization: Nostr <base64url-encoded JSON of the kind 24242 event>
```
Note: Amethyst uses standard Base64 (not Base64url), which works in practice.
### Endpoint Authorization Requirements
| Endpoint | `t` tag | Hash source | `x` tag |
|----------|---------|-------------|---------|
| GET/HEAD /<sha256> | `get` | URL path | optional |
| PUT /upload | `upload` | X-SHA-256 header | required |
| HEAD /upload | `upload` | X-SHA-256 header | required |
| DELETE /<sha256> | `delete` | URL path | required |
| GET /list/<pubkey> | `list` | -- | N/A |
| PUT /mirror | `upload` | mirrored blob hash | required |
| PUT /media | `media` | X-SHA-256 header | required |
| HEAD /media | `media` | X-SHA-256 header | required |
### Validation Checklist (Server)
1. Event kind == 24242
2. `created_at` < now
3. `expiration` > now
4. `t` tag matches endpoint action
5. `server` tags (if present) include this server's domain
6. `x` tags (if required) match the blob hash
### Security Note
Unscoped tokens (no `server` tag) can be replayed to other servers. Always scope `delete` tokens.
---
## Error Handling -- HTTP Status Codes
| Code | Meaning | When |
|------|---------|------|
| 200 | Success | GET, HEAD, successful upload/mirror/delete |
| 3xx | Redirect | GET with CDN redirect (must preserve hash in URL) |
| 400 | Bad Request | Invalid headers, malformed auth |
| 401 | Unauthorized | Missing or invalid authorization |
| 402 | Payment Required | BUD-07 paid servers |
| 403 | Forbidden | Hash banned, user blocked |
| 404 | Not Found | Blob doesn't exist |
| 411 | Length Required | Missing Content-Length / X-Content-Length |
| 413 | Content Too Large | File exceeds server limit |
| 415 | Unsupported Media Type | Server doesn't accept this MIME type |
All error responses MAY include `X-Reason` header.
---
## Popular Blossom Servers
| Server | Limits | Notes |
|--------|--------|-------|
| `blossom.nostr.build` | 100 MiB hard, 20 MiB free | Run by nostr.build team. Supports BUD-01,02,04,05,06,08 |
| `blossom.band` | 100 MiB hard, 20 MiB free | Community server |
| `blossom.primal.net` | Integrated with Primal stack | Uses /media by default, strips metadata |
| `cdn.satellite.earth` | Unknown | Satellite CDN |
| `blossom.azzamo.net` | Free tier + premium | Azzamo's server |
| `blosstr.com` | Enterprise-grade | Commercial offering |
Rate limiting is not standardized in the protocol. Each server implements its own policies. Free tiers generally have stricter limits. BUD-06 HEAD preflight is the mechanism for discovering server limitations before uploading.
---
## Client Implementations
### Amethyst (Kotlin -- upstream)
**Quartz library** (`quartz/src/commonMain/kotlin/.../nipB7Blossom/`):
- `BlossomAuthorizationEvent` -- Kind 24242 event creation (get, upload, delete, list)
- `BlossomServersEvent` -- Kind 10063 server list management
- `BlossomUploadResult` -- Blob Descriptor deserialization (kotlinx.serialization)
- `BlossomUri` -- BUD-10 URI parsing/serialization
**Android app** (`amethyst/src/main/java/.../service/uploads/blossom/`):
- `BlossomUploader` -- PUT /upload + DELETE implementation using OkHttp
- `BlossomServerResolver` -- BUD-10 URI resolution with LruCache
- `ServerHeadCache` -- HEAD request caching for blob existence checks
- `UploadOrchestrator` -- Orchestrates NIP-95, NIP-96, and Blossom uploads
**Upload flow:**
1. Read file, compute SHA-256 hash + size
2. Compute blurhash metadata locally
3. Create kind 24242 auth event (t=upload, x=hash, expiration=+1hr)
4. Base64-encode auth event JSON
5. PUT /upload with `Authorization: Nostr <base64>`, `Content-Type`, `Content-Length`
6. Parse Blob Descriptor response
7. Download + verify the uploaded file (re-hash check)
**Key:** Amethyst does NOT use `/media` endpoint. Uses `/upload` only. No mirroring implemented.
### Primal
- All Primal 2.2+ apps use `/media` (BUD-05) by default
- Strips all metadata before saving
- Optionally mirrors to other Blossom servers per user settings
- Deeply integrated into Primal stack, enabled by default
### Nostrify (TypeScript/Web)
```typescript
const uploader = new BlossomUploader({
servers: ['https://blossom.primal.net/'],
signer: window.nostr,
expiresIn: 60, // seconds
});
const tags = await uploader.upload(file);
// Returns NIP-94 tags: url, x, ox, size, m
```
- `ox` tag = original hash (before server processing)
- `x` tag = final hash (after optimization if /media used)
### NDK Blossom (@nostr-dev-kit/ndk-blossom)
npm package wrapping BUD-01 through BUD-06. TypeScript.
### Dart NDK (dart-nostr.com)
Has Blossom use case documentation. Flutter/Dart integration.
---
## Key Protocol Design Decisions
1. **Content addressing via SHA-256** -- Same file = same hash everywhere. Deduplication is free.
2. **Servers are interchangeable** -- Any server with the blob can serve it. URLs break? Find the hash elsewhere.
3. **No server-side processing on /upload** -- Bit-perfect storage. Hash computed over exact bytes received.
4. **/media is the exception** -- Trusted server processes/optimizes. New hash for result.
5. **Authorization is opt-in per endpoint** -- Servers choose what to protect.
6. **User controls server list** -- Kind 10063 event = user's preferred servers.
7. **Mirror for redundancy** -- Upload once, mirror to N servers.
---
## Unanswered Questions
- Does BUD-11 require base64url (no padding) or standard base64? Spec says base64url, Amethyst uses standard base64 -- servers seem to accept both.
- What's the recommended expiration window for auth tokens? Amethyst uses 1 hour.
- How do clients handle the `/media` flow when the optimized hash differs from original? Need to re-sign auth for mirror requests with the new hash.
- Is there a standard way to discover server capabilities (which BUDs supported)? Not currently -- no capability endpoint defined.
- How to handle upload failures mid-stream for large files? No chunked upload in spec.
- Server-side dedup behavior when same hash uploaded by different users? Implementation-specific.
@@ -0,0 +1,468 @@
# Brainstorm: Desktop Media — Full Parity
**Date:** 2026-03-16
**Status:** Draft
**Branch:** TBD (`feat/desktop-media`)
## What We're Building
Full media functionality for Amethyst Desktop — display, upload, gallery, lightbox, video playback, encrypted media, and desktop-native UX. Feature parity with Android Amethyst's media stack, adapted for mouse-first desktop interaction.
### Scope
| Feature | Included | Notes |
|---------|----------|-------|
| Image display in notes | Yes | Coil3 AsyncImage, blurhash previews |
| Video playback | Yes | VLCJ with bundled libvlc |
| Blossom upload | Yes | Extract to commons, Blossom-only (no NIP-96) |
| Drag-drop / clipboard paste | Yes | Essential desktop UX from day 1 |
| Lightbox / zoom | Yes | Full-screen media viewer with zoom |
| Image gallery carousel | Yes | Multi-image posts |
| Profile gallery (NIP-68) | Yes | Kind 20 picture posts, create + view |
| Video events (NIP-71) | Yes | Kind 21/22 display |
| Encrypted media (NIP-17 DMs) | Yes | Full send + receive |
| Media server management | Yes | Blossom server list (kind 10063) |
| Audio/voice playback | Yes | MP3, OGG, FLAC, WAV |
| Media compression | Yes | Desktop-adapted (Java ImageIO) |
| Blurhash generation on upload | Yes | Already in commons/ |
| EXIF stripping | Yes | Privacy: strip metadata before upload |
| Alt text / accessibility | Yes | NIP-92 imeta alt field |
### Out of Scope (for now)
- NIP-96 upload (deprecated, Blossom replaces it)
- Voice recording (microphone capture — desktop-specific, complex)
- Picture-in-picture video
- Live streaming (NIP-53)
- Torrent/magnet distribution
## Why This Approach
### Blossom Only (No NIP-96)
NIP-96 is officially marked "unrecommended: replaced by blossom APIs" in the NIP registry. Building desktop from scratch gives us the opportunity to skip legacy protocol support entirely.
**Blossom advantages:**
- Content-addressed (SHA-256) — files portable across servers
- Native mirroring (BUD-04) — upload once, mirror to N servers
- Server-side optimization (BUD-05) — `/media` endpoint
- Payment support (BUD-07) — Cashu/Lightning
- Clean URI scheme (BUD-10) — `blossom:<hash>.<ext>`
### Extraction Strategy — Layered Architecture
Android's BlossomUploader is tightly coupled to Android (`Context`, `Uri`, `ContentResolver`). We need to separate the HTTP upload protocol from platform file access.
**Layer 1: commons/commonMain — Pure Blossom protocol client**
- `BlossomClient` — HTTP PUT /upload, /mirror, /media, DELETE, GET /list. Takes `InputStream` + metadata. Returns `BlobDescriptor`. No platform deps.
- `BlossomAuthHelper` — Creates kind 24242 auth events, base64-encodes for Authorization header
- `BlossomServerDiscovery` — Queries kind 10063, resolves server list, caches
- `MediaUploadResult` — Result data class (already platform-agnostic)
- `UploadOrchestrator` — Coordinates upload to server + optional optimization + mirroring
- `MultiUploadOrchestrator` — Manages parallel upload of multiple files
- `MediaUploadTracker` — StateFlow-based progress tracking
- `ServerHeadCache` — BUD-06 pre-flight response cache
**Layer 2: commons/commonMain — expect/actual for platform file operations**
```
expect fun readFileBytes(path: String): ByteArray
expect fun computeFileSha256(path: String): String
expect fun getMimeType(path: String): String?
expect fun getFileSize(path: String): Long
expect class MediaMetadataExtractor {
fun extractDimensions(path: String): Pair<Int, Int>?
fun extractBlurhash(path: String): String?
}
expect class MediaCompressor {
fun compressImage(path: String, quality: Float): String
fun stripExif(path: String): String
}
```
**Layer 3: Platform actuals**
- `androidMain/` — Uses `ContentResolver`, `Uri`, Android Bitmap, `MediaMetadataRetriever`
- `jvmMain/` — Uses `java.io.File`, Java ImageIO, `metadata-extractor`, BufferedImage→blurhash
**Layer 4: Platform UI (desktopApp/ and amethyst/)**
- File pickers, drag-drop handlers, video players, lightbox composables
This design lets any future client (iOS, web) reuse Layer 1 + 2 by providing Layer 3 actuals.
### Coil3 for Image Loading
Coil3 officially supports Compose Multiplatform including JVM/Desktop. Android Amethyst already uses Coil3. Benefits:
- Disk + memory caching
- Custom fetchers (Blossom URI, blurhash, base64)
- Crossfade animations
- SVG support
### VLCJ for Video
ExoPlayer is Android-only (Media3). VLCJ wraps VLC's libvlc via JNA — supports every format VLC does (mp4, webm, m3u8, mkv, etc.). Compose integration via `SwingPanel` or offscreen rendering. We bundle libvlc with the app (~100MB) for zero user setup.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Upload protocol | Blossom only | NIP-96 deprecated |
| Upload code location | commons/commonMain | Share with Android |
| Image loading | Coil3 | Already used on Android, KMP support |
| Video playback | VLCJ (bundled libvlc) | ExoPlayer is Android-only |
| Desktop input | Drag-drop + paste + file picker | Essential for desktop UX |
| Encrypted media | Full support | DM file sharing parity |
| Profile gallery | Yes (NIP-68 kind 20) | Create + view |
## Existing Codebase Audit
### Already Shared (commons/)
| Component | Location | Status |
|-----------|----------|--------|
| BlurHashDecoder/Encoder | `commons/blurhash/` | Ready |
| PlatformImage (expect/actual) | `commons/blurhash/PlatformImage.kt` | Ready (JVM uses BufferedImage) |
| BitmapUtils (JVM) | `commons/blurhash/BitmapUtils.jvm.kt` | Ready |
| Base64ImagePlatform (JVM) | `commons/base64Image/` | Ready |
| RichTextParser | `commons/richtext/RichTextParser.kt` | Ready (classifies URLs as image/video) |
| MediaContentModels | `commons/richtext/MediaContentModels.kt` | Ready (MediaUrlImage, MediaUrlVideo, etc.) |
| UrlParser | `commons/richtext/UrlParser.kt` | Ready |
| Image extensions list | RichTextParser companion | png, jpg, gif, bmp, jpeg, webp, svg, avif |
| Video extensions list | RichTextParser companion | mp4, avi, wmv, mpg, amv, webm, mov + audio |
### In Quartz (protocol layer, shared)
| Component | Location | Status |
|-----------|----------|--------|
| BlossomAuthorizationEvent | `quartz/nipB7Blossom/` | Ready (kind 24242) |
| BlossomServersEvent | `quartz/nipB7Blossom/` | Ready (kind 10063) |
| BlossomUri | `quartz/nipB7Blossom/` | Ready (blossom: URI parsing) |
| BlossomUploadResult | `quartz/nipB7Blossom/` | Ready |
| FileHeaderEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) |
| BlurhashTag | `quartz/nip94FileMetadata/tags/` | Ready |
| DimensionTag | `quartz/nip94FileMetadata/tags/` | Ready |
| IMetaTag/Builder (NIP-92) | `quartz/nip92IMeta/` | Ready |
| PictureEvent (NIP-68) | `quartz/nip68Picture/` | Ready (kind 20) |
| VideoEvent (NIP-71) | `quartz/nip71Video/` | Ready (kind 21/22/34235/34236) |
| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | Ready |
| FileServersEvent (NIP-96) | `quartz/nip96FileStorage/` | Exists but we're skipping NIP-96 |
| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Ready (encrypted DM files) |
### Android-Only (needs extraction or desktop equivalent)
| Component | Location | Action |
|-----------|----------|--------|
| BlossomUploader | `amethyst/service/uploads/blossom/` | Extract HTTP logic to commons |
| Nip96Uploader | `amethyst/service/uploads/nip96/` | Skip (Blossom only) |
| UploadOrchestrator | `amethyst/service/uploads/` | Extract to commons |
| MultiOrchestrator | `amethyst/service/uploads/` | Extract to commons |
| MediaUploadResult | `amethyst/service/uploads/` | Extract (already platform-agnostic) |
| MediaCompressor | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO) |
| BlurhashMetadataCalculator | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO + commons blurhash) |
| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/` | Extract to commons |
| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/` | Extract to commons |
| ImageLoaderSetup | `amethyst/service/images/` | Desktop Coil3 config |
| BlossomFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blossom: URIs) |
| BlurHashFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blurhash) |
| Base64Fetcher | `amethyst/service/images/` | Extract to commons |
| ZoomableContentView | `amethyst/ui/components/` | Desktop lightbox equivalent |
| ZoomableContentDialog | `amethyst/ui/components/` | Desktop lightbox dialog |
| ImageGallery | `amethyst/ui/components/` | Extract carousel to commons |
| MyAsyncImage | `amethyst/ui/components/` | Desktop equivalent |
| VideoView/VideoViewInner | `amethyst/service/playback/` | Desktop video player (VLCJ) |
| ExoPlayerPool/Builder | `amethyst/service/playback/` | Desktop player pool equivalent |
| MediaAspectRatioCache | `amethyst/model/` | Extract to commons |
| NewMediaView/Model | `amethyst/ui/actions/` | Desktop media post composer |
| MediaUploadTracker | `amethyst/ui/actions/uploads/` | Extract to commons |
| SelectFromGallery | `amethyst/ui/actions/uploads/` | Desktop file picker |
| ShowImageUploadItem | `amethyst/ui/actions/uploads/` | Extract upload preview to commons |
| ChatFileSender/Uploader | `amethyst/ui/screen/chats/` | Desktop encrypted upload |
| BlossomServersViewModel | `amethyst/ui/actions/mediaServers/` | Extract to commons |
| GalleryThumb | `amethyst/ui/screen/profile/gallery/` | Desktop gallery grid |
| PictureDisplay | `amethyst/ui/note/types/` | Extract to commons |
| VideoDisplay | `amethyst/ui/note/types/` | Desktop video renderer |
| VoiceTrack | `amethyst/ui/note/types/` | Desktop audio player |
### Desktop-Specific (new code)
| Component | Location | Notes |
|-----------|----------|-------|
| DesktopImageLoader setup | `desktopApp/` or `commons/jvmMain/` | Coil3 disk cache config for desktop |
| DesktopVideoPlayer | `desktopApp/` | VLCJ wrapper composable |
| DesktopFilePicker | `desktopApp/` | JFileChooser / AWT FileDialog |
| DragDropHandler | `desktopApp/` | Compose Desktop DnD API |
| ClipboardPasteHandler | `desktopApp/` | AWT clipboard image reading |
| DesktopMediaCompressor | `commons/jvmMain/` | Java ImageIO-based compression |
| DesktopBlurhashCalculator | `commons/jvmMain/` | BufferedImage → blurhash |
| DesktopExifStripper | `commons/jvmMain/` | metadata-extractor (lossless EXIF removal) |
| MediaScreen (desktop) | `desktopApp/` | Desktop media gallery/feed layout |
| UploadDialog (desktop) | `desktopApp/` | Upload progress, server selection, alt text |
## Blossom Protocol Implementation
### Upload Flow (BUD-02)
```
1. User drops/pastes/picks file
2. Client computes SHA-256 locally
3. (Optional) HEAD /upload pre-flight check (BUD-06)
4. Sign kind 24242 auth event (BUD-11) with t=upload
5. PUT /upload with binary body + Authorization header
6. Server returns BlobDescriptor {url, sha256, size, type, uploaded}
7. (Optional) PUT /media for server-side optimization (BUD-05)
8. (Optional) PUT /mirror to additional servers (BUD-04)
9. Client adds imeta tag to note event (NIP-92)
```
### Server Discovery (BUD-03)
```
1. Query user's kind 10063 event from relays
2. Parse server URLs from ["server", "https://..."] tags
3. Cache server list
4. Upload to preferred server(s)
5. When URL breaks: extract SHA-256 from URL → try other servers
```
### Auth (BUD-11)
```kotlin
// Kind 24242 event structure
BlossomAuthorizationEvent(
content = "Upload Blob",
tags = [
["t", "upload"],
["x", "<sha256>"], // scope to specific blob
["expiration", "<timestamp>"], // required
["server", "cdn.example.com"] // scope to server
]
)
// Sent as: Authorization: Nostr <base64(signedEvent)>
```
## NIP Coverage
| NIP | What | How We Use It |
|-----|------|---------------|
| NIP-92 | Media Attachments (imeta tag) | Attach metadata to media URLs in notes |
| NIP-94 | File Metadata (kind 1063) | File header events, metadata tags |
| NIP-B7 | Blossom Media | Server discovery, URL fallback |
| NIP-68 | Picture Events (kind 20) | Picture-first posts, profile gallery |
| NIP-71 | Video Events (kind 21/22) | Video display and metadata |
| NIP-17 | Private DMs (encrypted files) | Encrypted media in DMs |
## Desktop UX Patterns
### Drag & Drop
- Drop zone on compose area
- Visual feedback (border highlight, preview)
- Multiple files → MultiOrchestrator
- Accept: images, videos, audio files
### Clipboard Paste (Ctrl+V)
- Detect image data in clipboard
- Auto-create temp file → upload flow
- Screenshot workflow: PrtScn → paste → upload
### File Picker
- OS-native dialog (JFileChooser on desktop)
- Filter by supported media types
- Multiple file selection
### Lightbox
- Click image → full-screen overlay
- Mouse wheel zoom + pan
- Arrow keys for gallery navigation
- Esc to close
- Save to disk option
### Upload Progress
- Inline progress indicator per file
- Server selection dropdown (from kind 10063 list)
- Alt text input field
- Compression toggle
- Preview before send
## Phases
### Phase 1: Image Display Foundation
**Goal:** Images render in desktop notes with blurhash previews.
| Task | Module | Details |
|------|--------|---------|
| Desktop Coil3 ImageLoader setup | `desktopApp/` | DiskCache (OS-appropriate path), MemoryCache, OkHttp network, custom fetchers |
| Extract BlossomFetcher | `amethyst/``commons/` | Coil3 fetcher for `blossom:` URIs |
| Extract BlurHashFetcher | `amethyst/``commons/` | Coil3 fetcher for blurhash placeholder rendering |
| Extract Base64Fetcher | `amethyst/``commons/` | Coil3 fetcher for base64 data URIs |
| Desktop inline image rendering | `desktopApp/` | AsyncImage in note content, aspect ratio handling |
| MediaAspectRatioCache extraction | `amethyst/``commons/` | LruCache for URL→aspect ratio (replace Android LruCache with common impl) |
**Deliverable:** Notes in desktop feed display inline images with blurhash placeholders.
**Verifiable:** Run desktop app, navigate to feed with image posts, images load with blue/gray previews → full images.
### Phase 2: Blossom Upload Protocol Extraction
**Goal:** Shared upload client in commons, usable by Android and Desktop.
| Task | Module | Details |
|------|--------|---------|
| Create `BlossomClient` | `commons/commonMain/` | HTTP PUT /upload, /mirror, /media. Takes InputStream. Returns BlobDescriptor. |
| Create `BlossomAuthHelper` | `commons/commonMain/` | Kind 24242 event creation + base64 encoding |
| Create `BlossomServerDiscovery` | `commons/commonMain/` | Kind 10063 query, server list resolution |
| Create expect/actual file operations | `commons/` | `readFileBytes`, `computeFileSha256`, `getMimeType`, `getFileSize` |
| Create expect/actual `MediaMetadataExtractor` | `commons/` | Dimensions, blurhash computation |
| Create expect/actual `MediaCompressor` | `commons/` | JPEG quality, EXIF stripping (metadata-extractor) |
| Extract `UploadOrchestrator` | `amethyst/``commons/` | Multi-server coordination using BlossomClient |
| Extract `MultiUploadOrchestrator` | `amethyst/``commons/` | Parallel file upload management |
| Extract `MediaUploadResult` | `amethyst/``commons/` | Already platform-agnostic |
| Migrate Android to use commons upload | `amethyst/` | Android actuals + wire up to existing UploadOrchestrator callers |
| Extract `BlossomServersViewModel` | `amethyst/``commons/` | Server list state management |
**Deliverable:** `./gradlew :commons:jvmTest` passes with upload unit tests. Android still works.
**Verifiable:** Android upload flow unchanged. Desktop can call BlossomClient to upload a file.
### Phase 3: Desktop Upload UX
**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste.
| Task | Module | Details |
|------|--------|---------|
| Desktop file picker | `desktopApp/` | JFileChooser with media type filters, multi-select |
| Drag-and-drop handler | `desktopApp/` | `dragAndDropTarget` + `awtTransferable` + `javaFileListFlavor` |
| Clipboard paste handler | `desktopApp/` | AWT Toolkit clipboard, `DataFlavor.imageFlavor`, temp file creation |
| Upload dialog composable | `desktopApp/` | Progress bar, server selector (kind 10063), alt text field, compression toggle |
| Upload preview | `desktopApp/` or `commons/` | Thumbnail preview before upload |
| Wire up to compose screen | `desktopApp/` | Add media button to note composer, connect upload flow |
**Deliverable:** Desktop user can drag image → see preview → upload to Blossom → post note with imeta.
**Verifiable:** Drop file on compose area, see upload progress, note publishes with embedded image.
### Phase 4: Video Playback
**Goal:** Videos play inline in desktop notes.
| Task | Module | Details |
|------|--------|---------|
| Add VLCJ + vlc-setup plugin | `desktopApp/build.gradle.kts` | `ir.mahozad.vlc-setup`, VLCJ 4.8.x dep |
| DesktopVideoPlayer composable | `desktopApp/` | SwingPanel + EmbeddedMediaPlayerComponent |
| Video controls overlay | `desktopApp/` | Play/pause, seek bar, volume, fullscreen toggle |
| Video in note rendering | `desktopApp/` | Replace URL-only display with inline player |
| Video upload support | `desktopApp/` | Accept video files in upload flow (Phase 3) |
**Deliverable:** Video posts play inline in desktop feed.
**Verifiable:** Navigate to note with mp4/webm URL, video plays with controls.
### Phase 5: Lightbox & Gallery
**Goal:** Full-screen media viewing with zoom and gallery navigation.
| Task | Module | Details |
|------|--------|---------|
| Lightbox overlay composable | `desktopApp/` or `commons/` | Full-screen overlay, semi-transparent backdrop |
| Zoom + pan | `desktopApp/` | Mouse wheel zoom, click-drag pan (use zoomable lib or custom) |
| Gallery carousel | `commons/commonMain/` | Multi-image navigation (arrow keys + swipe indicators) |
| Save to disk | `desktopApp/` | Right-click or button → save image/video to local filesystem |
| Keyboard shortcuts | `desktopApp/` | Esc close, Left/Right navigate, +/- zoom |
**Deliverable:** Click any image → fullscreen lightbox with zoom, multi-image gallery navigation.
**Verifiable:** Click image in note, zooms to fullscreen. Arrow keys cycle images. Esc closes.
### Phase 6: Encrypted Media (DM Files)
**Goal:** Send and receive encrypted files in NIP-17 DMs.
| Task | Module | Details |
|------|--------|---------|
| Extract encryption logic | `amethyst/``commons/` | NostrCipher usage for file encrypt/decrypt |
| Desktop encrypted upload flow | `desktopApp/` | Pick file → encrypt → Blossom upload → send encrypted event |
| Desktop encrypted display | `desktopApp/` | Receive encrypted file event → download → decrypt → display |
| Chat file upload dialog | `desktopApp/` | Similar to Phase 3 upload dialog but in DM context |
**Deliverable:** Desktop DM users can send/receive encrypted images and files.
**Verifiable:** Send image in DM from desktop, receive on Android (and vice versa).
### Phase 7: Profile Gallery (NIP-68)
**Goal:** View and create picture-first posts (kind 20). Profile gallery tab.
| Task | Module | Details |
|------|--------|---------|
| Picture event display | `desktopApp/` | Kind 20 renderer with image-first layout |
| Profile gallery tab | `desktopApp/` | Grid of user's picture posts |
| Picture post composer | `desktopApp/` | Create kind 20 events with multiple images + imeta |
| Gallery entry events | `desktopApp/` | ProfileGalleryEntryEvent support |
**Deliverable:** Desktop profile shows gallery tab. Users can create Instagram-style picture posts.
**Verifiable:** View profile → gallery tab shows image grid. Create picture post → visible on Android.
### Phase 8: Media Server Management
**Goal:** UI for managing Blossom server list (kind 10063).
| Task | Module | Details |
|------|--------|---------|
| Server list settings screen | `desktopApp/` | View/add/remove/reorder Blossom servers |
| Server status checking | `commons/` | HEAD request to verify server availability |
| Default server selection | `desktopApp/` | Choose preferred upload server |
| Publish kind 10063 | `commons/` | Update server list on relays |
**Deliverable:** Desktop settings page to manage Blossom servers.
**Verifiable:** Add server → appears in upload dialog dropdown. Remove server → no longer used.
### Phase 9: Audio Playback
**Goal:** Play audio tracks (MP3, OGG, FLAC) in notes.
| Task | Module | Details |
|------|--------|---------|
| Audio player composable | `desktopApp/` | VLCJ audio-only mode (no video surface needed) |
| Waveform visualization | `desktopApp/` | Optional: visual waveform for voice messages |
| Audio in note rendering | `desktopApp/` | Play/pause button + progress bar inline |
**Deliverable:** Audio files play inline in notes.
**Verifiable:** Note with MP3 URL shows audio player, plays on click.
### Phase Dependency Graph
```
Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox)
Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted)
├──→ Phase 7 (Gallery)
└──→ Phase 8 (Server Mgmt)
Phase 4 (Video) ────────────→ Phase 9 (Audio)
Independent: Phase 1, 2, 4 can run in parallel
```
## Assumptions
1. **Coil3 JVM/Desktop is production-ready** — Coil3 3.x advertises Compose Multiplatform support. Verify actual JVM desktop stability before committing.
2. **VLCJ + Compose SwingPanel works** — SwingPanel embeds Swing components in Compose. VLCJ renders to a Canvas/Panel. Need spike to confirm smooth integration (no flickering, proper resizing).
3. **OkHttp in commons is fine** — BlossomUploader uses OkHttp for HTTP. Both Android and Desktop are JVM, so OkHttp works in `commons/jvmAndroid/` or `commons/commonMain/` (OkHttp has KMP support). If iOS is ever targeted, this becomes an issue.
4. **Extracting to commons won't break Android** — Moving upload code from `amethyst/` to `commons/` requires updating Android imports. Must ensure Android's Koin DI and lifecycle wiring still works.
5. **libvlc can be bundled per-platform** — Compose Desktop packaging plugin supports native lib bundling. Need to verify for macOS (dylib), Linux (.so), Windows (.dll).
## Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| VLCJ SwingPanel flicker | Video unusable | Spike test early (Phase 4 is independent) |
| Commons extraction breaks Android | Regression | Run Android build after each extraction |
| Coil3 JVM disk cache bugs | Missing images | Fall back to OkHttp manual caching |
| libvlc bundle size (~100MB) | Large app | Consider optional download or separate installer |
| Scope creep (15 features) | Never ships | Phases exist for a reason — ship Phase 1-3 first |
## Resolved Questions
1. **Video player** — VLCJ with bundled libvlc. Ship libvlc with the app (~100MB) for zero user setup. Full format support (mp4, webm, m3u8, mkv, etc.).
2. **EXIF stripping** — Use Drew Noakes' `metadata-extractor` library. Surgically strip EXIF while preserving image quality (no re-encoding loss).
3. **Upload concurrency** — Higher parallelism than Android by default (desktop has more resources). Upload to multiple Blossom servers simultaneously. Make concurrent upload count user-configurable in settings.
4. **Coil3 disk cache** — Use OS-appropriate paths: macOS `~/Library/Caches/AmethystDesktop`, Linux `$XDG_CACHE_HOME/AmethystDesktop` (default `~/.cache/`), Windows `%LOCALAPPDATA%/AmethystDesktop/cache`. Use `maxSizeBytes(1GB)` not `maxSizePercent` (that needs Android Context).
5. **Compose Desktop DnD**`Modifier.dragAndDropTarget` is experimental (`@ExperimentalFoundationApi`) in 1.7.x. Uses `event.awtTransferable` + `DataFlavor.javaFileListFlavor` on desktop. Old `onExternalDrag` deprecated, removed in 1.8.0. API works but expect minor changes.
6. **Media compression** — JPEG: `ImageWriteParam.compressionQuality` (0.0-1.0). PNG: lossless deflate level. WebP: use `org.sejda.imageio:webp-imageio` for lossy/lossless write (~3MB native libs per platform). Start with JPEG/PNG re-encoding, add WebP output later.
7. **libvlc bundling** — Use `ir.mahozad.vlc-setup` Gradle plugin. Downloads and bundles libvlc per platform. Targets VLC 3.x + VLCJ 4.8.x. Compose Desktop integration via `SwingPanel`.
## Open Questions
1. **vlc-setup Apple Silicon** — Does the vlc-setup plugin support arm64 macOS, or only x86_64? Need to verify.
2. **Compose DnD macOS quirks** — Does `awtTransferable` properly deliver file URIs on macOS, or are there Finder-specific issues?
@@ -0,0 +1,85 @@
# Brainstorm: Desktop DM Encrypted Media (Phase 6)
**Date:** 2026-03-18
**Status:** Ready for planning
**Branch:** `feat/desktop-media`
## What We're Building
Full send + receive encrypted media support in desktop DM chat (NIP-17). Users can attach files in DMs, which get encrypted (AES-GCM) before upload to Blossom, sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap. Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay.
## Why This Approach
The protocol layer is complete in quartz (NIP-17, NIP-44, AESGCM, ChatMessageEncryptedFileHeaderEvent). Android has the full flow implemented. Desktop already has `EncryptedMediaService.downloadAndDecrypt()` and `DesktopUploadOrchestrator` (unencrypted only). The work is essentially wiring up the existing pieces with a desktop-native UX.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Scope | Full send + receive | Both sides needed for complete DM file sharing |
| Attach UX | Inline paperclip button | Matches desktop chat conventions; thumbnails above text input |
| Drag & drop | Yes, in addition to button | Desktop-native interaction; ComposeNoteDialog already has this pattern |
| Encryption indicator | Lock icon overlay | Small lock on corner of encrypted media in chat bubbles |
| Upload encryption | AES-GCM via AESGCM class | Matches Android's ChatFileUploader.justUploadNIP17() pattern |
| Event type | Kind 15 (ChatMessageEncryptedFileHeaderEvent) | NIP-17 standard for encrypted file metadata |
## Architecture Overview
### Send Flow (Desktop)
```
User attaches file → thumbnail preview above input
→ Click send
→ Generate AES-GCM cipher (key + nonce)
→ DesktopUploadOrchestrator.uploadEncrypted(cipher, file)
→ Encrypt file bytes with cipher
→ Upload encrypted blob to Blossom server
→ Build ChatMessageEncryptedFileHeaderEvent (kind 15)
→ URL, encryption algo/key/nonce, file metadata
→ Wrap in GiftWrap (NIP-59) for each recipient
→ Send to relays
```
### Receive Flow (Desktop)
```
Receive GiftWrap → unwrap → ChatMessageEncryptedFileHeaderEvent
→ Extract URL, encryption key, nonce from tags
→ EncryptedMediaService.downloadAndDecrypt(url, key, nonce)
→ Display decrypted media inline in chat bubble
→ Lock icon overlay on media thumbnail
```
## Existing Code to Reuse
| Component | Location | Action |
|-----------|----------|--------|
| AESGCM cipher | `quartz/utils/ciphers/AESGCM.kt` | Reuse as-is |
| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Reuse as-is |
| NIP17Factory (GiftWrap) | `quartz/nip17Dm/NIP17Factory.kt` | Reuse as-is |
| EncryptedMediaService | `desktopApp/service/media/EncryptedMediaService.kt` | Extend for UI integration |
| DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Add `uploadEncrypted()` |
| ChatPane | `desktopApp/ui/chats/ChatPane.kt` | Add attach button, thumbnails, drag-drop |
| ChatMessageCompose | `commons/ui/chat/ChatMessageCompose.kt` | Add encrypted media display |
| Android ChatFileUploader | `amethyst/chats/privateDM/send/upload/` | Reference pattern |
## New Code Needed
| Component | Location | Purpose |
|-----------|----------|---------|
| `uploadEncrypted()` | DesktopUploadOrchestrator | Encrypt file with AESGCM before Blossom upload |
| DM file attach UI | ChatPane.kt | Paperclip button, thumbnail row, drag-drop zone |
| DM file send logic | ChatPane.kt or new helper | Build kind 15 event from upload result + cipher |
| Encrypted media renderer | ChatMessageCompose or new composable | Download, decrypt, display with lock overlay |
| `sendNip17EncryptedFile()` | DesktopIAccount.kt | Bridge to relay manager for sending |
## Open Questions
None — all key decisions resolved through brainstorm dialogue.
## Test Cases (from testing plan)
| # | Test | Expected |
|---|------|----------|
| 6.1 | DM file attach | Attach button visible, encryption indicator shown |
| 6.2 | Send encrypted | File uploads encrypted to Blossom, kind 15 event sent |
| 6.3 | Receive encrypted | Encrypted file downloads, decrypts, displays in bubble |
| 6.4 | Wrong key | Decryption fails gracefully (no crash, error state) |
@@ -0,0 +1,75 @@
# Brainstorm: Stacked Messages Layout in Multi-Deck
**Date:** 2026-03-19
**Status:** Ready for planning
## What We're Building
Replace the side-by-side split-pane Messages layout (contact list + chat) with a stacked navigation in deck columns. Clicking a conversation navigates from the contact list to the chat view; a back arrow returns to the list. Single-pane (non-deck) mode keeps the current split layout.
## Why This Approach
In multi-deck mode, columns can be 350-400dp wide. The current split layout allocates 280dp to the contact list, leaving only 70-120dp for the chat pane — unusable. A stacked layout gives the full column width to whichever view is active.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Layout mode | Always stacked in deck columns | Simplest, works at any column width |
| Single-pane mode | Keep split layout | Plenty of horizontal space in single-pane |
| Back navigation | Back arrow in chat header | Discoverable; Escape key already works |
| State management | `selectedRoom` in `ChatroomListState` already controls this | No new state needed — just show list when null, chat when selected |
## Architecture
### Current Flow (DesktopMessagesScreen.kt)
```
Row {
ConversationListPane(280dp) // always visible
VerticalDivider
ChatPane(flex) // or EmptyState
}
```
### New Flow (Deck Mode)
```
// When selectedRoom == null:
ConversationListPane(full width)
// When selectedRoom != null:
Column {
BackArrow + ChatroomHeader
ChatPane(full width)
}
```
### Implementation Approach
`DesktopMessagesScreen` already has `selectedRoom` state. The change is layout-only:
1. Add a `compactMode: Boolean` parameter to `DesktopMessagesScreen`
2. In deck mode (`compactMode = true`): show either list OR chat, not both
3. In single-pane mode (`compactMode = false`): keep current split Row
4. Add back arrow to `ChatPane` header when in compact mode
5. `clearSelection()` → back to list (already exists)
### What Changes
| Component | Change |
|-----------|--------|
| `DesktopMessagesScreen` | Add `compactMode` param; conditional layout (Row vs when/else) |
| `ChatPane` | Add `onBack` callback + back arrow in header when provided |
| `RootContent` | Pass `compactMode = true` to DesktopMessagesScreen |
| `SinglePaneLayout` `RootContent` | Pass `compactMode = false` |
| `ConversationListPane` | No changes — already works at any width |
### Edge Cases
- **Keyboard nav**: Escape already calls `clearSelection()` — works as back
- **New DM dialog**: Opens over whichever view is active — no change needed
- **Receiving DM while in list**: Room appears/updates in list normally
- **Receiving DM while in chat**: Messages appear in real-time — no change
## Open Questions
None — all decisions resolved.
@@ -0,0 +1,33 @@
# Brainstorm: Per-Message Encryption Badge in DMs
**Date:** 2026-03-19
**Status:** Ready for implementation
## What We're Building
Per-message encryption indicator in DM chat bubbles — lock icon (NIP-17) or lock-open icon (NIP-04) next to the timestamp. Matches Android's approach with incognito badges.
## Why This Approach
Users need to know which messages are truly private (NIP-17: relay can't see sender/recipient) vs legacy encrypted (NIP-04: relay sees metadata). Android already does this with incognito badges. Desktop uses lock/lock-open icons (already imported in ChatPane) for consistency with the existing NIP-17 toggle.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Indicator type | Lock icon per message | Matches existing lock icon pattern in desktop NIP-17 toggle |
| NIP-17 icon | Lock (filled) in primary color | Private, secure |
| NIP-04 icon | LockOpen in muted gray | Legacy, weaker privacy |
| Placement | Next to timestamp in detailRow | Matches Android's IncognitoBadge placement |
| Tooltip | None (match Android) | Keep it subtle, not alarming |
## Implementation
The badge goes in `MessageWithReactions` in ChatPane.kt, in the `detailRow` slot of `ChatMessageCompose`. Check `note.event` type:
- `is PrivateDmEvent` → NIP-04 → lock-open gray
- `is ChatMessageEvent` or `is ChatMessageEncryptedFileHeaderEvent` → NIP-17 → lock primary
- else → no badge
## Key Finding: NIP-04 + NIP-17 Messages Merge
For 1-on-1 chats, both protocols produce identical `ChatroomKey({otherPubkey})`. Messages from both protocols appear in the same conversation. The per-message badge is the only way to tell them apart.
@@ -0,0 +1,168 @@
# Manual Testing Plan: Desktop Media Full Parity (`feat/desktop-media`)
## Context
Branch has 13 commits implementing Phases 0-9 of desktop media: image display, upload, video/audio playback, lightbox, encrypted DM files, profile gallery, server management, and imeta tags. 49 unit tests cover service logic. This plan covers **integration & UI manual testing** that unit tests can't reach.
## Prerequisites
- VLC installed on system (required for VLCJ video/audio)
- Logged into a Nostr account with signing capability
- At least one reachable Blossom server (e.g. `https://blossom.primal.net`)
- Test files ready: JPEG (with EXIF), PNG, GIF, SVG, MP4, MP3, large file (>10MB)
## Run Command
```bash
./gradlew :desktopApp:run
```
---
## Phase 1: Image Display & Caching
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ✅ PASS |
| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ✅ PASS |
| 1.3 | Animated GIF | Open note with GIF URL | GIF animates with all frames | ✅ PASS (was static-only, fixed with AnimatedGifImage composable) |
| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ✅ PASS |
| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ✅ PASS |
| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/AmethystDesktop/image_cache/` | ✅ PASS |
| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ✅ PASS |
---
## Phase 2: File Upload (Blossom Protocol)
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 2.1 | Upload JPEG | Compose note → attach JPEG → publish | Upload succeeds, URL in note | ✅ PASS |
| 2.2 | Upload PNG | Compose note → attach PNG → publish | Upload succeeds, URL in note | ✅ PASS |
| 2.3 | EXIF stripped | Upload JPEG with GPS EXIF → download result from Blossom URL | No EXIF metadata in downloaded file | ✅ PASS (implicit — DesktopMediaCompressor strips EXIF before upload) |
| 2.4 | Auth header | Upload with Nostr signer | Server accepts upload (BUD-11 auth works) | ✅ PASS (uploads succeed = auth works) |
| 2.5 | Upload progress | Attach large file → upload | Progress indicator updates during upload | ✅ PASS |
| 2.6 | Upload error | Disconnect network mid-upload | Error state shown, no crash | ✅ PASS |
| 2.7 | Server selector | Open compose → attach file → check server selector | Shows configured Blossom servers; dropdown to switch | ✅ PASS (fixed: added ServerSelector shown when files attached) |
---
## Phase 3: Upload UX (File Picker, Paste, Drag-Drop)
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 3.1 | File picker | Compose → click Attach → select image | Native dialog opens, file appears in attachment row | ✅ PASS |
| 3.2 | Multi-select | File picker → select 3 images | All 3 appear in attachment row with thumbnails | ✅ PASS |
| 3.3 | File type filter | File picker dialog | Only media files shown (images, video, audio) | ✅ PASS |
| 3.4 | Clipboard paste | Copy image → Compose → Cmd+V/Ctrl+V | Pasted image appears in attachment row | ✅ PASS |
| 3.5 | Drag & drop | Drag image file onto compose dialog | File appears in attachment row; visual drop indicator | ✅ PASS |
| 3.6 | Remove attachment | Click X on attached file | File removed from row | ✅ PASS |
| 3.7 | Thumbnail preview | Attach image | Thumbnail visible in attachment row | ✅ PASS |
| 3.8 | Alt text | Attach image → enter alt text → publish | Alt text included in imeta tag | ✅ PASS |
| 3.9 | Imeta tags | Publish note with image | Published event has imeta tag (url, m, x, dim, blurhash) | ✅ PASS |
---
## Phase 4: Video Playback (VLCJ)
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 4.1 | Inline video | Open note with MP4 URL | Video player renders inline (not just URL text) | ✅ PASS |
| 4.2 | Play/pause | Click play button | Video plays; click again pauses | ✅ PASS |
| 4.3 | Seek bar | Drag seek bar | Video jumps to position | ✅ PASS |
| 4.4 | Volume control | Adjust volume slider | Audio level changes | ✅ PASS (widened slider 80dp→240dp for usability) |
| 4.5 | Aspect ratio | Videos with 16:9 and 4:3 | Correct aspect ratio maintained | ✅ PASS |
| 4.6 | Controls auto-hide | Play video, don't move mouse for 2s | Controls fade out; reappear on mouse move | ✅ PASS |
| 4.7 | Player pool limit | Scroll through 5+ video notes | Max 3 players active; earlier ones release | ✅ PASS |
| 4.8 | WebM format | Note with WebM URL | Plays correctly (if VLC supports it) | ✅ PASS |
---
## Phase 5: Lightbox & Gallery Navigation
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 5.1 | Open lightbox | Click image in feed/profile/thread | Full-screen overlay with dark backdrop | ✅ PASS (fixed: lightbox in Box overlay, split click zones in NoteCard) |
| 5.2 | Close (X button) | Click X button top-left | Lightbox closes | ✅ PASS (added X close button) |
| 5.3 | Close (Esc) | Press Escape | Lightbox closes | ✅ PASS |
| 5.4 | Zoom (scroll) | Mouse wheel up/down | Image zooms in/out (up to 10x) | ✅ PASS |
| 5.5 | Pan (drag) | Zoom in → click-drag | Image pans with cursor | ✅ PASS |
| 5.6 | Reset zoom | Double-click image | Zoom resets to fit | ✅ PASS (added onDoubleTap handler) |
| 5.7 | Multi-image nav | Open note with 3+ images → click one | Arrow buttons visible; left/right navigate | ✅ PASS |
| 5.8 | Arrow key nav | Left/Right arrow keys | Navigate between images | ✅ PASS |
| 5.9 | Index indicator | Multi-image gallery | Shows "1 / 5" style counter | ✅ PASS (added bottom-center pill counter) |
| 5.10 | Save to disk | Cmd+S / Ctrl+S in lightbox | Save dialog opens; file downloads to chosen path | ✅ PASS (fixed: added isMetaPressed for macOS) |
| 5.11 | Video in lightbox | Click video thumbnail | Video plays in lightbox with controls | ✅ PASS |
---
## Phase 6: Encrypted Media (NIP-17 DMs)
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail with lock icon visible above input | ⬜ TODO — implemented, needs manual test |
| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom, kind 15 in GiftWrap | ⬜ TODO — implemented, needs manual test |
| 6.3 | Receive encrypted | Receive DM with encrypted file from another client | File downloads and decrypts; displays correctly | ⬜ TODO — ChatFileAttachment implemented |
| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO |
---
## Phase 7: Profile Gallery (NIP-68 / Kind 20)
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 7.1 | Gallery tab visible | Navigate to profile | "Gallery" tab appears | ✅ PASS |
| 7.2 | Grid layout | Click Gallery tab | Thumbnail grid of kind 20 posts | ⬜ TODO — needs user with kind 20 posts to verify |
| 7.3 | Blurhash thumbs | Gallery loading | Blurhash placeholders before full thumbnails | ⬜ TODO |
| 7.4 | Click → lightbox | Click gallery thumbnail | Opens lightbox at that image | ⬜ TODO |
| 7.5 | Empty gallery | Profile with no picture posts | Empty state "No pictures yet" | ✅ PASS |
| 7.6 | Picture post display | Kind 20 note in feed | Shows image-first layout with title + description | ⬜ TODO — needs kind 20 content |
| 7.7 | Create picture post | Compose kind 20 with multiple images | Multi-image post publishes with imeta tags | ⬜ BLOCKED — no kind 20 compose UI |
---
## Phase 8: Blossom Server Management
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 8.1 | Server list loads | Settings → Media Servers | Shows servers from kind 10063 | ✅ PASS |
| 8.2 | Health check | Click refresh on a server | Green/red/grey with hover tooltip | ✅ PASS (added tooltip: Online/Offline/Checking) |
| 8.3 | Check all | Click "Check All" | All servers checked in parallel | ✅ PASS |
| 8.4 | Add server | Enter URL → click Add | Server appears in list; health check runs | ✅ PASS |
| 8.5 | Remove server | Click delete on a server | Server removed from list | ✅ PASS |
| 8.6 | Set default server | Click "Set as default" on a server | Server moves to top of list | ✅ PASS (added set-as-default action) |
| 8.7 | Publish to relays | Add/remove server → check relays | Kind 10063 event updated on relays | ⬜ TODO — needs relay inspector to verify |
| 8.8 | Invalid server URL | Add "not-a-url" | Add button disabled | ✅ PASS (added URL validation) |
| 8.9 | Settings scroll | Scroll settings page | Content scrolls | ✅ PASS (fixed: added verticalScroll) |
---
## Phase 9: Audio Playback
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 9.1 | Inline audio | Note with MP3 URL | Audio player renders inline | ✅ PASS |
| 9.2 | Play/pause | Click play | Audio plays; click again pauses | ✅ PASS |
| 9.3 | Seek | Drag seek bar | Playback jumps to position | ✅ PASS |
| 9.4 | Time display | Play audio file | Shows current/total time | ✅ PASS |
| 9.5 | Multiple formats | Notes with OGG, WAV, FLAC, AAC, OPUS, M4A | All play (where VLC supports) | ⬜ TODO |
| 9.6 | Audio pool | Scroll past 6+ audio notes | Max 5 audio players; earlier ones release | N/A — GlobalMediaPlayer uses single shared player now |
| 9.7 | Initial volume | Play audio without touching volume slider | Audio audible at 100% on first play | 🐛 BUG — VLC starts silent; moving volume slider fixes it. Tried `:start-volume`, `setVolume` in playing callback, delayed retries — none work. Needs investigation into VLCJ audio output init timing on macOS. |
---
## Phase 10: Cross-Cutting / Edge Cases
| # | Test | Steps | Expected | Status |
|---|------|-------|----------|--------|
| 10.1 | No VLC installed | Remove VLC from PATH → run app | Graceful fallback for video/audio (no crash) | |
| 10.2 | Large file upload | Upload 50MB video | Handles without OOM; progress shown | |
| 10.3 | Rapid scrolling | Scroll feed with many images quickly | No memory leak, images load on demand | |
| 10.4 | Window resize | Resize window while viewing gallery/feed | Layout adapts; no clipping | |
| 10.5 | Multiple uploads | Attach 5 files → upload all → publish | All upload, all get imeta tags | |
| 10.6 | App restart | Restart app after uploads/config | Cache, server prefs, all persisted | |
---
## Unanswered Questions
- Is VLCJ arm64 macOS working? (vlcj-setup plugin uncertain for Apple Silicon)
- Can we test encrypted DM file sharing without a second account/client?
- Should we test SVG rendering separately or is Coil3 SVG decoder sufficient?
- How to verify kind 10063 publish without a relay inspector tool?
@@ -0,0 +1,833 @@
---
title: "feat: Desktop Media — Full Parity"
type: feat
status: active
date: 2026-03-16
origin: docs/brainstorms/2026-03-16-desktop-media-brainstorm.md
deepened: 2026-03-16
---
# Desktop Media — Full Parity
## Enhancement Summary
**Deepened on:** 2026-03-16
**Research agents used:** Architecture Strategist, Performance Oracle, Security Sentinel, Code Simplicity Reviewer, Pattern Recognition Specialist, Race Condition Reviewer, Coil3 Best Practices Researcher, VLCJ Framework Docs Researcher, Blossom Protocol Researcher, Compose Desktop DnD/Clipboard Researcher, KMP Expect/Actual Pattern Analyzer
### Critical Corrections (from original plan)
| # | Original Assumption | Correction |
|---|-------------------|------------|
| 1 | `metadata-extractor` for EXIF stripping | **READ-ONLY library.** Use Apache Commons Imaging `ExifRewriter.removeExifMetadata()` for lossless JPEG EXIF removal |
| 2 | `LinkedHashMap.removeEldestEntry` for caches | **NOT thread-safe**`get()` in access-order mode mutates internal linked list. Use existing `androidx.collection.LruCache` (already KMP dep in commons) or `ConcurrentHashMap` |
| 3 | `coil-gif` works on JVM desktop | **Android-only**`AnimatedImageDecoder` requires Android API 28+. Use `org.jetbrains.skia.Codec` for GIF decoding (first frame or animated) |
| 4 | VLCJ via `SwingPanel` | **Flickering confirmed.** Use DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image` (no SwingPanel needed) |
| 5 | Missing `kotlinx-coroutines-swing` dep | Required for `Dispatchers.Main.immediate` on JVM desktop with Coil3 |
| 6 | BlossomFetcher "just move" to commons | **Not trivial** — depends on `BlossomServerResolver` which uses Android `LruCache` + `IRoleBasedHttpClientBuilder`. Must abstract resolver interface first |
| 7 | UploadOrchestrator "extract" to commons | Really a **REWRITE** — deeply coupled to `Context`, `Uri`, `R.string`, `Account`, `ServerType` enum with NIP-95/96/Blossom paths |
| 8 | `vlc-setup` plugin ready for production | **No confirmed arm64 macOS support.** Only tested on Intel Mac (High Sierra). macOS support marked "experimental" |
| 9 | Coil3 memory cache "just works" | **Hard-codes 512MB total memory on non-Android.** Must set `maxSizeBytes()` explicitly using `Runtime.getRuntime().maxMemory()` |
| 10 | `MediaUploadResult` has no platform deps | **Hidden dependency** on `BlurhashWrapper` which imports from Android-only `BlurHashFetcher.kt` |
### Key Improvements from Research
1. **VLCJ DirectRendering pattern** — Complete working code from ComposeVideoPlayer project (no SwingPanel)
2. **Coil3 JVM cookbook**`PlatformContext.INSTANCE`, explicit cache sizing, stable Keyer for custom fetchers
3. **Blossom full spec** — BUD-01 through BUD-11 documented with HTTP examples and auth flow
4. **Compose DnD modern API**`Modifier.dragAndDropTarget` (old `onExternalDrag` removed in 1.8), `text/uri-list` fallback for Linux
5. **Race condition mitigations** — 13 identified (3 CRITICAL), with concrete fixes
6. **Simplicity alternative** — Desktop-only code first, extract to commons later (0 users → ship fast)
---
## Overview
Add complete media functionality to Amethyst Desktop: image display, video playback, Blossom upload, drag-drop/paste UX, lightbox, gallery, encrypted DM media, profile gallery, server management, and audio playback. Currently desktop renders **zero media** — notes show URL text only, no inline images or video.
## Problem Statement
Desktop Amethyst is text-only. The `NoteCard` at `desktopApp/ui/note/NoteCard.kt:128-132` renders URLs as blue underlined text via `RichTextContent`. No `AsyncImage`, no Coil dependency, no ImageLoader setup. `UserAvatar` uses `coil3.compose.AsyncImage` from commons but likely fails silently — no `SingletonImageLoader` configured for desktop.
Android has 40+ media-related files across upload, display, playback, and gallery. All are tightly coupled to Android APIs (`Context`, `Uri`, `ContentResolver`, `BitmapFactory`, `MediaMetadataRetriever`, `ExoPlayer`).
## Proposed Solution
Extract platform-agnostic media logic to commons using a 4-layer architecture, then build desktop-specific UI on top. Blossom-only upload (no NIP-96). Coil3 for images (already KMP). VLCJ with DirectRendering for video.
(see brainstorm: `docs/brainstorms/2026-03-16-desktop-media-brainstorm.md`)
### Simplicity Consideration
The simplicity reviewer recommends a **desktop-only-first** approach: write desktop code in `desktopApp/` without extracting to commons. Extract later when Android migration happens. Rationale: desktop has zero users, so ship fast and iterate. This plan documents the full extraction architecture but **implementation should start desktop-only** for Phases 0-3, deferring commons extraction to a follow-up PR.
## Technical Approach
### Architecture: 4-Layer Extraction
```
Layer 1: commons/commonMain — Pure protocol (BlossomClient, auth, server discovery)
Layer 2: commons/commonMain — expect/actual file operations
Layer 3: commons/{androidMain,jvmMain} — Platform actuals
Layer 4: desktopApp/ and amethyst/ — Platform UI
```
### Research Insight: jvmAndroid Source Set
The codebase already has a `jvmAndroid` intermediate source set in `commons/build.gradle.kts` that bridges Android and Desktop JVM code. OkHttp-based HTTP clients work identically on both — place shared JVM code there, not in `commonMain` (which would try to compile for iOS/web targets).
### Key Architectural Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Upload protocol | Blossom only | NIP-96 deprecated (see brainstorm) |
| Image loading | Coil3 3.4.0 | Already in project, KMP support |
| Video playback | VLCJ 4.8.x + DirectRendering | SwingPanel flickers; DirectRendering uses CallbackVideoSurface → Skia Bitmap |
| Upload code | Desktop-only first, extract later | Ship fast, 0 desktop users, extract when Android migrates |
| Desktop input | Drag-drop + paste + file picker | Essential desktop UX |
| EXIF stripping | Apache Commons Imaging | `metadata-extractor` is read-only; Commons Imaging does lossless EXIF removal |
| Video bundling | Require system VLC for now | `vlc-setup` plugin has no confirmed arm64 macOS support |
| GIF decoding | `org.jetbrains.skia.Codec` | `coil-gif` is Android-only; Skia Codec decodes frames |
| Cache implementation | `androidx.collection.LruCache` | Already KMP dep in commons; thread-safe unlike `LinkedHashMap` |
### Existing Codebase Inventory
**Already shared (ready to use):**
| Component | Location |
|-----------|----------|
| BlurHashDecoder/Encoder | `commons/blurhash/` |
| PlatformImage expect/actual | `commons/blurhash/PlatformImage.kt``.android.kt` / `.jvm.kt` |
| Base64ImagePlatform | `commons/base64Image/` (JVM uses ImageIO) |
| RichTextParser (URL → media type) | `commons/richtext/RichTextParser.kt` |
| MediaContentModels | `commons/richtext/MediaContentModels.kt` |
| UserAvatar (AsyncImage) | `commons/ui/components/UserAvatar.kt` |
| GalleryParser | `commons/richtext/GalleryParser.kt` |
| Blossom protocol events | `quartz/nipB7Blossom/` (kind 24242, 10063, URI, UploadResult) |
| NIP-94 FileHeader | `quartz/nip94FileMetadata/` |
| NIP-92 IMetaTag | `quartz/nip92IMeta/` |
| NIP-68 PictureEvent | `quartz/nip68Picture/` |
| NIP-71 VideoEvent/VideoMeta | `quartz/nip71Video/` |
| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` |
| `androidx.collection.LruCache` | Already in commons dependencies (KMP) |
**Android-only (needs extraction or desktop equivalent):**
| Component | File | Android Deps | Action |
|-----------|------|-------------|--------|
| BlossomUploader | `amethyst/service/uploads/blossom/BlossomUploader.kt` | ContentResolver, Uri, Context, MimeTypeMap | **Rewrite** HTTP core for desktop |
| UploadOrchestrator | `amethyst/service/uploads/UploadOrchestrator.kt` | Context, Uri, R.string, Account, ServerType | **Rewrite** — too coupled for extraction |
| MultiOrchestrator | `amethyst/service/uploads/MultiOrchestrator.kt` | Context | Defer |
| MediaUploadResult | `amethyst/service/uploads/MediaUploadResult.kt` | Hidden BlurhashWrapper dep | Fix dep chain first |
| MediaCompressor | `amethyst/service/uploads/MediaCompressor.kt` | Context, Bitmap, Uri, Compressor | expect/actual |
| BlurhashMetadataCalculator | `amethyst/service/uploads/BlurhashMetadataCalculator.kt` | Context, BitmapFactory, MediaMetadataRetriever | expect/actual |
| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt` | LruCache (Android), IRoleBasedHttpClientBuilder | Replace LruCache with `androidx.collection.LruCache` (KMP) |
| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt` | None | Move (safe) |
| ImageLoaderSetup | `amethyst/service/images/ImageLoaderSetup.kt` | Android SingletonImageLoader, GIF decoder (API 28+) | Desktop equivalent |
| ImageCacheFactory | `amethyst/service/images/ImageCacheFactory.kt` | Context for safeCacheDir | Desktop paths |
| BlossomFetcher | `amethyst/service/images/BlossomFetcher.kt` | Depends on BlossomServerResolver (Android LruCache) | Abstract resolver interface first |
| BlurHashFetcher | `amethyst/service/images/BlurHashFetcher.kt` | toAndroidBitmap() | JVM: toBufferedImage() |
| Base64Fetcher | `amethyst/service/images/Base64Fetcher.kt` | toBitmap() | JVM: toBufferedImage() |
| ZoomableContentView | `amethyst/ui/components/ZoomableContentView.kt` | Android zoom lib | Desktop equivalent |
| ImageGallery | `amethyst/ui/components/ImageGallery.kt` | Pure Compose | Extract |
| MediaAspectRatioCache | `amethyst/model/MediaAspectRatioCache.kt` | Android LruCache | `androidx.collection.LruCache` (KMP) |
### Dependency Changes
**desktopApp/build.gradle.kts — add:**
```kotlin
// Image loading (Coil3)
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
implementation(libs.coil.svg)
// NOTE: coil-gif is Android-only, skip it. Use Skia Codec instead.
// Coroutines — required for Coil3 Main dispatcher on JVM desktop
implementation(libs.kotlinx.coroutines.swing)
// Video playback (VLCJ)
implementation("uk.co.caprica:vlcj:4.8.3")
// EXIF stripping (lossless)
implementation("org.apache.commons:commons-imaging:1.0.0-alpha5")
```
**gradle/libs.versions.toml — add:**
```toml
vlcj = "4.8.3"
commons-imaging = "1.0.0-alpha5"
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
```
### Research Insight: Coil3 JVM Desktop Configuration
```kotlin
// Critical: PlatformContext.INSTANCE (not Android Context)
// Critical: Set memory cache explicitly (Coil3 hard-codes 512MB total on non-Android)
// Critical: Set disk cache to OS-appropriate persistent path (default is temp dir)
fun createDesktopImageLoader(): ImageLoader {
return ImageLoader.Builder(PlatformContext.INSTANCE)
.memoryCache {
MemoryCache.Builder()
.maxSizeBytes(calculateDesktopMemoryCacheSize())
.strongReferencesEnabled(true)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(getDesktopCacheDir().resolve("amethyst/image_cache").toOkioPath())
.maxSizeBytes(512L * 1024 * 1024) // 512 MB
.build()
}
.precision(Precision.INEXACT)
.crossfade(true)
.components {
add(SvgDecoder.Factory()) // Works on JVM via Skia SVG
// add(SkiaGifDecoder.Factory()) // Custom: first-frame GIF via Codec
// add(BlossomFetcher.Factory(...))
}
.build()
}
fun calculateDesktopMemoryCacheSize(): Long {
val maxMemory = Runtime.getRuntime().maxMemory()
return (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024)
}
fun getDesktopCacheDir(): File {
val os = System.getProperty("os.name").lowercase()
return when {
"mac" in os -> File(System.getProperty("user.home"), "Library/Caches")
"win" in os -> File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home"))
else -> File(System.getenv("XDG_CACHE_HOME") ?: "${System.getProperty("user.home")}/.cache")
}
}
```
### Research Insight: Skia GIF Decoding (replaces coil-gif)
```kotlin
// org.jetbrains.skia.Codec provides frame-by-frame GIF decoding
// Skia supports: PNG, JPEG, WebP (static), BMP, ICO, WBMP
// GIF: first frame via Image.makeFromEncoded, full animation via Codec
// Animated WebP: first frame only (same Codec approach for animation)
// HEIC: NOT supported on JVM
class SkiaGifDecoder(private val source: ImageSource) : Decoder {
override suspend fun decode(): DecodeResult {
val bytes = source.source().use { it.readByteArray() }
val data = org.jetbrains.skia.Data.makeFromBytes(bytes)
val codec = org.jetbrains.skia.Codec.makeFromData(data)
val bitmap = org.jetbrains.skia.Bitmap()
bitmap.allocN32Pixels(codec.width, codec.height)
codec.readPixels(bitmap, 0) // first frame
bitmap.setImmutable()
return DecodeResult(image = bitmap.asImage(), isSampled = false)
}
class Factory : Decoder.Factory { /* check mimeType == "image/gif" */ }
}
```
### Research Insight: VLCJ DirectRendering Pattern
```kotlin
// NO SwingPanel — renders directly to Skia Bitmap → Compose Image
val callbackVideoSurface = CallbackVideoSurface(
object : BufferFormatCallback {
override fun getBufferFormat(w: Int, h: Int): BufferFormat {
info = ImageInfo.makeN32(w, h, ColorAlphaType.OPAQUE)
return RV32BufferFormat(w, h)
}
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {
byteArray = ByteArray(buffers[0].limit())
}
},
object : RenderCallback {
override fun display(mp: MediaPlayer, bufs: Array<out ByteBuffer>, fmt: BufferFormat?) {
bufs[0].get(byteArray); bufs[0].rewind()
val bmp = Bitmap(); bmp.allocPixels(info!!); bmp.installPixels(byteArray)
imageBitmap = bmp.asComposeImageBitmap() // triggers recomposition
}
},
true, VideoSurfaceAdapters.getVideoSurfaceAdapter()
)
// Then display via: Image(bitmap = imageBitmap, ...)
```
**Performance:** 2 frame copies per display frame. ~8MB/frame at 1080p. Adequate for 1-2 players, CPU-intensive for more. New ByteArray per frame required (reusing crashes).
### Research Insight: Compose Desktop Drag-and-Drop
```kotlin
// Modern API (1.8+): Modifier.dragAndDropTarget
// Old onExternalDrag was REMOVED in 1.8
// awtTransferable + DataFlavor.javaFileListFlavor for files
// text/uri-list fallback needed for Linux + browser drops
// Cannot inspect file types during drag hover — filter in onDrop
val dropTarget = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val transferable = event.awtTransferable ?: return false
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
onFilesDropped(files)
return true
}
return false
}
}
}
```
**Clipboard:** Compose `ClipboardManager` is text-only. Use AWT `Toolkit.getDefaultToolkit().systemClipboard` with `DataFlavor.imageFlavor` for image paste, `DataFlavor.javaFileListFlavor` for file paste.
---
## Implementation Phases
### Phase 0: Spike — Coil3 + VLCJ Desktop Validation
**Goal:** Confirm Coil3 loads images on JVM desktop with disk cache. Confirm VLCJ DirectRendering works in Compose.
| # | Task | File | Details |
|---|------|------|---------|
| 0.1 | Coil3 spike | `desktopApp/spike/CoilSpike.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with DiskCache (persistent path), MemoryCache (explicit size), load HTTP image via AsyncImage |
| 0.2 | VLCJ spike | `desktopApp/spike/VlcjSpike.kt` | DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image`. Test mp4 URL. **NO SwingPanel** |
| 0.3 | Validate SVG | spike | Test `coil-svg` + `SvgDecoder.Factory()` — confirmed KMP-compatible via Skia SVG |
| 0.4 | Validate GIF (first frame) | spike | Test `Image.makeFromEncoded(bytes)` for static GIF. Optionally test `Codec` for frame count |
| 0.5 | Add `kotlinx-coroutines-swing` | `desktopApp/build.gradle.kts` | Required for `Dispatchers.Main.immediate` |
**Gate:** If VLCJ DirectRendering drops frames badly or Coil3 disk cache fails, evaluate alternatives (Klarity for video, OkHttp cache interceptor for images).
**Deliverable:** Spike branch with working video + image rendering.
**Effort:** 1-2 days.
### Research Insight: Spike Simplification
The simplicity reviewer notes Phase 0 can be a 20-line test in `main()` — no need for separate spike files. Just add deps, create `ImageLoader`, call `AsyncImage` in the existing desktop window.
---
### Phase 1: Image Display Foundation
**Goal:** Images render inline in desktop notes with blurhash previews.
| # | Task | Module | Details |
|---|------|--------|---------|
| 1.1 | Add Coil3 deps to desktopApp | `desktopApp/build.gradle.kts` | `coil-compose`, `coil-okhttp`, `coil-svg`, `kotlinx-coroutines-swing`. **NOT** `coil-gif` (Android-only) |
| 1.2 | Create DesktopImageLoaderSetup | `desktopApp/service/images/DesktopImageLoaderSetup.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with explicit MemoryCache size (25% of JVM heap, max 512MB), DiskCache (OS-appropriate persistent path), OkHttp, SvgDecoder, custom fetchers. Package: `com.vitorpamplona.amethyst.desktop.service.images` |
| 1.3 | Create DesktopImageCacheFactory | `desktopApp/service/images/DesktopImageCacheFactory.kt` | macOS: `~/Library/Caches/AmethystDesktop`, Linux: `$XDG_CACHE_HOME/AmethystDesktop`, Windows: `%LOCALAPPDATA%/AmethystDesktop/cache`. `maxSizeBytes(512MB)` |
| 1.4 | Create desktop BlossomFetcher | `desktopApp/service/images/DesktopBlossomFetcher.kt` | **Desktop-only first** (don't extract to commons yet). Simplified version without `IRoleBasedHttpClientBuilder`. Uses single OkHttpClient for now. Resolves `blossom:` URIs via kind 10063 server list |
| 1.5 | Create desktop BlurHashFetcher | `desktopApp/service/images/DesktopBlurHashFetcher.kt` | Uses `PlatformImage.toBufferedImage()``BitmapImage(toComposeImageBitmap())`. Implement stable `Keyer` to avoid recomposition flicker |
| 1.6 | Create desktop Base64Fetcher | `desktopApp/service/images/DesktopBase64Fetcher.kt` | Uses existing `Base64ImagePlatform.jvm.kt` with ImageIO |
| 1.7 | Create MediaAspectRatioCache | `desktopApp/model/MediaAspectRatioCache.kt` | Use `androidx.collection.LruCache(1000)` (already KMP dep). **NOT** `LinkedHashMap` (not thread-safe) |
| 1.8 | Update NoteCard for inline images | `desktopApp/ui/note/NoteCard.kt` | Parse imeta tags from events. When URL matches image extension → `AsyncImage` with blurhash placeholder. Use `RichTextParser` media classification |
| 1.9 | Initialize ImageLoader in Main.kt | `desktopApp/Main.kt` | Call `setSingletonImageLoaderFactory` at app startup. Use `@OptIn(DelicateCoilApi::class)` for eager init |
| 1.10 | Add SkiaGifDecoder | `desktopApp/service/images/SkiaGifDecoder.kt` | Custom Coil3 `Decoder` using `org.jetbrains.skia.Codec` for GIF first-frame rendering |
**Deliverable:** Notes with images show blurhash placeholder → full image. `UserAvatar` also starts working.
**Acceptance Criteria:**
- [ ] `./gradlew :desktopApp:compileKotlin` passes
- [ ] Desktop app shows inline images in feed notes
- [ ] Blurhash placeholders display while loading
- [ ] SVG images render correctly (via SvgDecoder)
- [ ] GIF shows first frame (static display is acceptable for MVP)
- [ ] `UserAvatar` renders profile pictures
- [ ] Disk cache persists across app restarts (not in temp dir)
- [ ] Android build unbroken: `./gradlew :amethyst:compileDebugKotlin`
### Research Insight: Coil3 Custom Fetcher Gotchas
- Use `coil3.Uri` not `android.net.Uri`
- `ConnectivityChecker` is a no-op on desktop (always returns "connected")
- Custom fetchers **must** implement stable `Keyer` or cache key — otherwise recomposition triggers re-fetch ([#2551](https://github.com/coil-kt/coil/issues/2551))
- `PlatformContext.INSTANCE` is an empty singleton on JVM — don't cast or use Android methods
---
### Phase 2: Desktop Blossom Upload Client
**Goal:** Upload media from desktop to Blossom servers.
**Approach:** Write a **desktop-only** upload client in `desktopApp/`. Don't extract to commons yet (simplicity-first). The Android upload path stays unchanged.
| # | Task | Module | Details |
|---|------|--------|---------|
| 2.1 | Create DesktopBlossomClient | `desktopApp/service/upload/DesktopBlossomClient.kt` | HTTP PUT `/upload`, `/mirror`, `/media`, DELETE. Takes `File` + metadata. Returns `BlossomUploadResult` (from quartz). Uses OkHttp. Package: `com.vitorpamplona.amethyst.desktop.service.upload` |
| 2.2 | Create DesktopBlossomAuthHelper | `desktopApp/service/upload/DesktopBlossomAuthHelper.kt` | Creates kind 24242 auth events via quartz `BlossomAuthorizationEvent`, base64-encodes for `Authorization: Nostr <b64>` header. Include `t`, `x`, `expiration`, `server` tags per BUD-11 |
| 2.3 | Create DesktopServerDiscovery | `desktopApp/service/upload/DesktopServerDiscovery.kt` | Queries kind 10063 from relays, resolves server list, caches with `androidx.collection.LruCache` |
| 2.4 | Create DesktopMediaMetadata | `desktopApp/service/upload/DesktopMediaMetadata.kt` | `ImageIO.read(file)` for dimensions, `BlurHashEncoder` (from commons) for blurhash, file size + SHA-256 |
| 2.5 | Create DesktopMediaCompressor | `desktopApp/service/upload/DesktopMediaCompressor.kt` | JPEG: `ImageWriteParam.compressionQuality`. EXIF strip: Apache Commons Imaging `ExifRewriter.removeExifMetadata()`. No re-encoding for EXIF removal |
| 2.6 | Create DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Coordinate: strip EXIF → compute metadata → auth → upload. Accept `File` directly (not Uri). Blossom-only (no NIP-95/96) |
| 2.7 | Create DesktopMediaUploadTracker | `desktopApp/service/upload/DesktopMediaUploadTracker.kt` | Simple `mutableStateOf(isUploading: Boolean)` for MVP (matches existing Android's 47-line tracker). StateFlow upgrade later |
| 2.8 | BUD-06 preflight check | `desktopApp/service/upload/ServerHeadCache.kt` | HEAD `/upload` with `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers. Cache results |
| 2.9 | Unit tests | `desktopApp/src/jvmTest/` | Mock OkHttp responses. Test upload, auth header format (base64 of kind 24242), SHA-256 computation, EXIF stripping |
**Deliverable:** `./gradlew :desktopApp:jvmTest` passes. Desktop can upload files to Blossom.
**Acceptance Criteria:**
- [ ] BlossomClient uploads file to test server (integration test or mock)
- [ ] Auth headers correctly signed (kind 24242 with `t=upload`, `x=<hash>`, `expiration`, `server`)
- [ ] SHA-256 computed correctly for test files
- [ ] EXIF stripped losslessly from JPEG test image
- [ ] BUD-06 preflight HEAD works
- [ ] Android build unbroken
### Research Insight: Blossom Protocol Details
**Upload flow per BUD-02:**
1. Compute SHA-256 of exact file bytes
2. Create kind 24242 event: `t=upload`, `x=<sha256>`, `expiration=+1hr`, `server=<domain>`
3. Base64-encode (standard base64 works despite spec saying base64url)
4. `PUT /upload` with `Authorization: Nostr <b64>`, `Content-Type`, `Content-Length`, `X-SHA-256`
5. Response: `BlobDescriptor` JSON with `url`, `sha256`, `size`, `type`, `uploaded`
**`/upload` vs `/media` (BUD-05):**
- `/upload` — server MUST NOT modify blob. Hash preserved.
- `/media` — server MAY optimize (strip EXIF, compress). Returned hash differs. Primal uses this exclusively.
- **Recommendation:** Use `/upload` first (simpler). Add `/media` support later for trusted servers.
**Auth token security (BUD-11):**
- Always include `server` tag to prevent token replay across servers
- Unscoped `delete` tokens can be replayed — always scope delete operations
- Include `x` tag (blob hash) for upload/delete operations
### Research Insight: Race Conditions in Upload
**CRITICAL:** Upload scope cancellation on navigation. If user navigates away during upload, the coroutine scope gets cancelled. Mitigation: launch uploads in a `GlobalScope` or `applicationScope` that survives navigation, with a reference in the tracker.
**HIGH:** DnD during active upload. User drops new files while upload in progress. Mitigation: queue uploads, don't replace in-flight uploads.
---
### Phase 3: Desktop Upload UX
**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste.
| # | Task | Module | Details |
|---|------|--------|---------|
| 3.1 | Desktop file picker | `desktopApp/ui/media/DesktopFilePicker.kt` | `java.awt.FileDialog` (native look) or `JFileChooser` with media type filters. Multi-select support |
| 3.2 | Drag-and-drop handler | `desktopApp/ui/media/DragDropHandler.kt` | `Modifier.dragAndDropTarget` (modern API, 1.8+). `event.awtTransferable` + `DataFlavor.javaFileListFlavor`. Include `text/uri-list` fallback for Linux. Visual drop zone indicator via `onEntered`/`onExited` callbacks |
| 3.3 | Clipboard paste handler | `desktopApp/ui/media/ClipboardPasteHandler.kt` | AWT `Toolkit.getDefaultToolkit().systemClipboard`. Check `DataFlavor.imageFlavor` (→ save BufferedImage to temp file → upload) and `DataFlavor.javaFileListFlavor` (→ direct file upload) |
| 3.4 | Upload dialog composable | `desktopApp/ui/media/UploadDialog.kt` | Progress bar per file, server selector (kind 10063 list), alt text input, cancel button |
| 3.5 | Upload preview thumbnails | `desktopApp/ui/media/UploadPreview.kt` | Thumbnail generation from local file using ImageIO |
| 3.6 | Wire upload to ComposeNoteDialog | `desktopApp/ui/ComposeNoteDialog.kt` | Add "Attach media" button. Connect to file picker + drag-drop. On upload complete → insert imeta tag + URL into note |
| 3.7 | Media button in note composer | `desktopApp/ui/ComposeNoteDialog.kt` | IconButton (Attach) → opens file picker. Shows attached files with remove option |
**Deliverable:** Drop/paste/pick file → preview → upload → note publishes with embedded image.
**Acceptance Criteria:**
- [ ] File picker opens, filters by media type, multi-select works
- [ ] Drag file onto compose area shows drop zone highlight
- [ ] Clipboard paste (Ctrl+V / Cmd+V) detects images and files
- [ ] Upload progress shows per-file
- [ ] Server selection from kind 10063 list
- [ ] Alt text saved in imeta tag
- [ ] Published note contains correct imeta tags
- [ ] Cancel upload works mid-flight
### Research Insight: DnD Platform Quirks
| Platform | Behavior | Note |
|----------|----------|------|
| macOS Finder | `javaFileListFlavor` works | Aliases resolve to alias file (use `canonicalFile`) |
| Windows Explorer | `javaFileListFlavor` works | Stable |
| Linux Nautilus | `javaFileListFlavor` works | Some WMs only provide `text/uri-list` |
| Browser drag | Usually `text/uri-list` | Not `javaFileListFlavor` — handle separately |
| **Cannot inspect file types during drag hover** | Only know flavor (files vs text), not extensions | Filter in `onDrop`, show generic "drop files here" during hover |
---
### Phase 4: Video Playback (VLCJ DirectRendering)
**Goal:** Videos play inline in desktop notes.
| # | Task | Module | Details |
|---|------|--------|---------|
| 4.1 | Add VLCJ deps | `desktopApp/build.gradle.kts` | `uk.co.caprica:vlcj:4.8.3`. **No vlc-setup plugin** — require system VLC for now (arm64 macOS not confirmed for plugin) |
| 4.2 | DesktopVideoPlayer composable | `desktopApp/ui/media/DesktopVideoPlayer.kt` | DirectRendering: `CallbackVideoSurface``RV32BufferFormat``ByteBuffer.get(byteArray)``Skia Bitmap.installPixels()``asComposeImageBitmap()` → Compose `Image`. Lazy init player |
| 4.3 | Video controls overlay | `desktopApp/ui/media/VideoControls.kt` | Play/pause, seek bar, volume slider, fullscreen toggle, time display. Auto-hide after 2s. Compose overlay on top of Compose Image (no z-order issues since no SwingPanel) |
| 4.4 | Video in note rendering | `desktopApp/ui/note/NoteCard.kt` | When URL matches video extension → show thumbnail + play button. On click → load VLCJ player |
| 4.5 | Video upload support | `desktopApp/ui/media/UploadDialog.kt` | Accept video files in upload flow. No transcoding — upload as-is |
| 4.6 | Video thumbnail generation | `desktopApp/service/media/VideoThumbnailGenerator.kt` | Use VLCJ `snapshots().get(320, 0)` on a dedicated player. Play → seek to 2s → pause → snapshot → stop |
| 4.7 | VLCJ player pool | `desktopApp/service/media/VlcjPlayerPool.kt` | Single `MediaPlayerFactory`, pool of 2-3 reusable `EmbeddedMediaPlayer` instances. Reuse players (`media().play(newMrl)`) instead of create/destroy. Strong references to prevent GC crash |
**Deliverable:** Video posts play inline. Controls work. Resource-efficient.
**Acceptance Criteria:**
- [ ] mp4, webm URLs play inline (m3u8 if VLC supports)
- [ ] Play/pause, seek, volume controls functional
- [ ] Fullscreen toggle works
- [ ] Video pauses when scrolled out of view
- [ ] Player instances pooled (max 3 active)
- [ ] Graceful fallback if VLC init fails (show URL link)
- [ ] VLC not installed → show "Install VLC" prompt with download link
### Research Insight: VLCJ Lifecycle Critical Rules
1. **Never let player instances get garbage collected** — native callbacks crash JVM if Java object is collected
2. **Keep strong references** to `MediaPlayerFactory`, `EmbeddedMediaPlayer`, and video surfaces
3. **Reuse players** — one factory, pool of players. Change media with `media().play(newMrl)`
4. **Release order:** player first, then factory
5. **macOS: only CallbackVideoSurface works** — no heavyweight AWT components since Java 7
6. **Audio-only:** Use `MediaPlayerFactory("--no-video")` for audio tracks
### Research Insight: Race Conditions in Video
**CRITICAL:** VLCJ use-after-release segfault. If `DisposableEffect.onDispose` releases player while a `RenderCallback.display()` is executing on the VLC thread → native crash. Mitigation: state machine per player slot. Set `RELEASING` state, wait for in-flight render to complete, then release.
**CRITICAL:** Rapid navigation between notes with video. User scrolls fast → player allocated → scrolled away before `mediaPlayerReady` event → player released during initialization. Mitigation: debounce player allocation (300ms visibility threshold before allocating).
---
### Phase 5: Lightbox & Gallery
**Goal:** Full-screen media viewing with zoom and gallery navigation.
| # | Task | Module | Details |
|---|------|--------|---------|
| 5.1 | Lightbox overlay | `desktopApp/ui/media/LightboxOverlay.kt` | Full-window overlay with semi-transparent backdrop. Click outside to close |
| 5.2 | Zoom + pan | `desktopApp/ui/media/ZoomableImage.kt` | Mouse wheel zoom, click-drag pan. `graphicsLayer` with `scaleX/Y` + `translationX/Y`. Double-click to reset |
| 5.3 | Gallery carousel | `desktopApp/ui/media/GalleryCarousel.kt` | Multi-image navigation with left/right buttons and indicator dots |
| 5.4 | Save to disk | `desktopApp/ui/media/SaveMediaAction.kt` | Button → `JFileChooser` save dialog. Download URL → write to chosen path |
| 5.5 | Keyboard shortcuts | `desktopApp/ui/media/LightboxOverlay.kt` | Esc=close, Left/Right=navigate, +/-=zoom, Ctrl+S=save, Space=play/pause (video) |
| 5.6 | Extract ImageGallery layout logic | `amethyst/ui/components/ImageGallery.kt``commons/` | The 1-5+ image grid layout is pure Compose — extract to commons for reuse |
**Deliverable:** Click image → fullscreen lightbox with zoom. Multi-image carousel. Keyboard navigation.
**Acceptance Criteria:**
- [ ] Click any inline image opens lightbox
- [ ] Mouse wheel zooms in/out smoothly
- [ ] Click-drag pans when zoomed
- [ ] Arrow keys navigate gallery
- [ ] Esc closes lightbox
- [ ] Save downloads file to disk
- [ ] Video plays in lightbox with controls
### Research Insight: Race Condition — Lightbox Double-Click
**MEDIUM:** User double-clicks an image. First click opens lightbox, second click registers as "click outside" → closes immediately. Mitigation: 200ms debounce on lightbox open/close transitions.
---
### Phase 6: Encrypted Media (NIP-17 DM Files)
**Goal:** Send and receive encrypted files in DMs.
| # | Task | Module | Details |
|---|------|--------|---------|
| 6.1 | Verify encryption is shared | `quartz/nip17Dm/files/` | `ChatMessageEncryptedFileHeaderEvent` (kind 15) already in commonMain with AESGCM cipher from quartz utils. Should work on desktop as-is |
| 6.2 | Encrypted upload flow | `desktopApp/ui/chat/` | Pick file → encrypt with NIP-44 → Blossom upload → create ChatMessageEncryptedFileHeaderEvent |
| 6.3 | Encrypted download + display | `desktopApp/ui/chat/` | Receive encrypted file event → download from Blossom → decrypt → display/save |
| 6.4 | Chat file upload UI | `desktopApp/ui/chat/ChatFileUploader.kt` | Similar to Phase 3 upload dialog but in DM context. Show encryption indicator |
**Deliverable:** Desktop DM users can send/receive encrypted images and files.
**Acceptance Criteria:**
- [ ] Send image in DM from desktop, visible on Android
- [ ] Receive encrypted image from Android, displays on desktop
- [ ] Non-participants cannot view encrypted media
- [ ] Upload progress shown in chat compose
---
### Phase 7: Profile Gallery (NIP-68)
**Goal:** View and create picture-first posts (kind 20). Profile gallery tab.
| # | Task | Module | Details |
|---|------|--------|---------|
| 7.1 | Picture event display | `desktopApp/ui/note/PictureDisplay.kt` | Kind 20 renderer. Image-first layout with title + description below. Uses imeta tags for multi-image |
| 7.2 | Profile gallery tab | `desktopApp/ui/profile/GalleryTab.kt` | Grid layout of user's picture posts. Lazy grid with thumbnails |
| 7.3 | Picture post composer | `desktopApp/ui/media/PicturePostComposer.kt` | Create kind 20 events. Multi-image upload + imeta + title + description |
| 7.4 | Gallery entry event support | `desktopApp/ui/profile/GalleryTab.kt` | Read `ProfileGalleryEntryEvent` (kind 1163) for curated galleries |
**Deliverable:** Profile shows gallery tab with image grid. Users can create picture posts.
**Acceptance Criteria:**
- [ ] Profile screen shows Gallery tab
- [ ] Gallery grid loads thumbnails with blurhash
- [ ] Click thumbnail opens lightbox
- [ ] Can create kind 20 picture post from desktop
- [ ] Picture post visible on Android Amethyst
---
### Phase 8: Media Server Management
**Goal:** UI for managing Blossom server list (kind 10063).
| # | Task | Module | Details |
|---|------|--------|---------|
| 8.1 | Server list settings screen | `desktopApp/ui/settings/MediaServerSettings.kt` | View/add/remove servers. Drag to reorder |
| 8.2 | Server status checking | `desktopApp/service/media/ServerHealthCheck.kt` | HEAD request to verify availability. Show green/red indicator |
| 8.3 | Default server selection | settings UI | Mark preferred upload server. Persist in Preferences |
| 8.4 | Publish kind 10063 | Uses quartz `BlossomServersEvent` | Update on relays when user modifies list |
**Deliverable:** Settings page to manage Blossom servers.
**Acceptance Criteria:**
- [ ] Server list loads from kind 10063 event
- [ ] Add/remove servers updates list
- [ ] Health check shows server status
- [ ] Changes published to relays
- [ ] Upload dialog reflects server list
---
### Phase 9: Audio Playback
**Goal:** Play audio tracks (MP3, OGG, FLAC, WAV) inline in notes.
| # | Task | Module | Details |
|---|------|--------|---------|
| 9.1 | Audio player composable | `desktopApp/ui/media/AudioPlayer.kt` | VLCJ with `MediaPlayerFactory("--no-video")`. Play/pause + progress bar + time. No video surface needed |
| 9.2 | Audio in note rendering | `desktopApp/ui/note/NoteCard.kt` | URL matches audio extension → show inline audio player |
| 9.3 | Waveform visualization (optional) | `desktopApp/ui/media/AudioWaveform.kt` | Simple amplitude bars. Low priority — skip if VLCJ doesn't expose PCM |
**Deliverable:** Audio files play inline in notes.
**Acceptance Criteria:**
- [ ] MP3, OGG, FLAC URLs show audio player
- [ ] Play/pause and seek work
- [ ] Multiple audio players on screen don't conflict
### Phase Dependency Graph
```
Phase 0 (Spike) ──→ Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox)
Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted)
├──→ Phase 7 (Gallery)
└──→ Phase 8 (Server Mgmt)
Phase 4 (Video) ────────────→ Phase 9 (Audio)
Independent: Phase 0 first. Then Phase 1, 2, 4 can run in parallel.
Ship Phases 0-3 as first PR. Phases 4-9 are incremental follow-ups.
```
## System-Wide Impact
### Interaction Graph
- Image display: NoteCard → RichTextParser → MediaContentModels → Coil3 ImageLoader → custom Fetchers → OkHttp → Blossom servers
- Upload: ComposeNoteDialog → DesktopFilePicker/DragDrop/Clipboard → DesktopMediaCompressor → DesktopBlossomClient → OkHttp → Blossom server → imeta tag → relay broadcast
- Auth chain: DesktopUploadOrchestrator → DesktopBlossomAuthHelper → quartz BlossomAuthorizationEvent → Account.signer → kind 24242 → base64 header
### Error & Failure Propagation
| Error Source | Handling |
|-------------|----------|
| Coil3 load failure | Show blurhash if available, else placeholder icon. Log error |
| Blossom upload network error | Retry with exponential backoff (3 attempts). Show error in upload dialog |
| VLCJ init failure (VLC not installed) | Show "Install VLC" prompt with download link. Fall back to URL link |
| SHA-256 mismatch after upload | Re-upload. Warn user if persistent |
| Server unreachable (BUD-06 preflight) | Skip server, try next in list |
| Encrypted media decrypt failure | Show "Unable to decrypt" placeholder |
| EXIF strip failure | Upload anyway with warning (privacy risk) |
### Race Condition Mitigations (from review)
| Severity | Issue | Mitigation |
|----------|-------|------------|
| **CRITICAL** | LinkedHashMap concurrent corruption | Use `androidx.collection.LruCache` (thread-safe) or `ConcurrentHashMap` |
| **CRITICAL** | VLCJ use-after-release segfault | State machine per player: `IDLE``LOADING``PLAYING``RELEASING`. Guard `RenderCallback.display()` with state check |
| **CRITICAL** | Upload scope cancelled on navigation | Launch uploads in application-scoped coroutine (survives navigation) |
| HIGH | Stale blurhash/image flash in lazy list | Stable `Keyer` for Coil3 custom fetchers |
| HIGH | DnD during active upload | Queue uploads, don't replace in-flight |
| HIGH | Non-atomic progress + state update | Use `MutableStateFlow<UploadState>` (single source of truth, atomic update) |
| HIGH | Pause-after-dispose (VLCJ) | Check player state before calling `controls().pause()` |
| MEDIUM | Stale server cache | TTL on server discovery cache (5 min) |
| MEDIUM | Lightbox double-click | 200ms debounce on open/close |
| MEDIUM | Gallery rapid navigation | Debounce image load requests |
| MEDIUM | StateFlow progress frequency | Throttle upload progress updates to 100ms intervals |
| LOW | Clipboard + DnD concurrent | Serialize input handling (queue) |
| LOW | Video control timer overlap | Single timer job for auto-hide |
### State Lifecycle Risks
| Risk | Mitigation |
|------|------------|
| Upload in progress + user navigates away | Application-scoped coroutine. Upload continues in background. Show notification on completion |
| VLCJ player leak | `DisposableEffect` with state machine release. Player pool enforces max count. Strong references prevent GC crash |
| Coil disk cache corruption | Coil3 handles internally. Worst case: re-download |
| Partial upload (network cut) | SHA-256 pre-computed. Server rejects incomplete uploads. Client retries |
### API Surface Parity
| Interface | Android | Desktop | Same? |
|-----------|---------|---------|-------|
| BlossomClient | Android-specific | Desktop-specific | **Different** (for now — extract to commons later) |
| UploadOrchestrator | Android-specific | Desktop-specific | **Different** (for now) |
| ImageLoader fetchers | Android-specific | Desktop-specific | **Different** (for now — same Coil3 API though) |
| VideoPlayer | ExoPlayer | VLCJ DirectRendering | Different |
| FilePicker | ActivityResultContracts | JFileChooser / FileDialog | Different |
| MediaCompressor | Zelory + MediaCodec | ImageIO + Commons Imaging | Different |
### Integration Test Scenarios
1. **Upload round-trip:** Desktop uploads image → published note with imeta → other client sees image via Blossom URL
2. **Cross-platform encrypted DM:** Android sends encrypted image → Desktop receives, decrypts, displays
3. **Blossom fallback:** Primary server down → resolver tries secondary server from kind 10063
4. **Gallery consistency:** Create kind 20 on desktop → visible in Android profile gallery
5. **Video lifecycle:** Scroll feed with 5 video notes → only 2-3 VLCJ players active → no OOM
## Alternative Approaches Considered
| Approach | Why Rejected |
|----------|-------------|
| NIP-96 + Blossom | NIP-96 deprecated. Double protocol = double maintenance (see brainstorm) |
| JavaFX MediaView for video | Adds JavaFX runtime (~50MB), conflicts with Compose Desktop rendering |
| SwingPanel for VLCJ | Confirmed flickering + always-on-top z-order issues. DirectRendering avoids entirely |
| Extract to commons immediately | Over-engineering for 0 desktop users. Ship desktop-only first, extract when needed |
| FFmpeg JNI for video | Complex native binding. VLCJ wraps VLC which bundles FFmpeg internally |
| Ktor instead of OkHttp | OkHttp already used everywhere. Both are JVM. No benefit to switching |
| `metadata-extractor` for EXIF strip | **Read-only library**. Only reads EXIF, cannot remove it |
| `LinkedHashMap` for caches | **Not thread-safe** in access-order mode. `get()` mutates internal state |
| Klarity (FFmpeg-based) | Interesting alternative (65 stars, Feb 2026 update), but small community. Worth prototyping as VLCJ backup |
## Risk Analysis & Mitigation
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|------------|
| VLCJ DirectRendering CPU overhead at 1080p | Medium | Medium | ~8MB/frame copy. Adequate for 1-2 players. Pool limits exposure |
| VLC not installed on user system | High | High | Show clear "Install VLC" prompt. Link to VLC download. Future: bundle VLC |
| vlc-setup plugin no arm64 macOS | Confirmed | Medium | Don't use plugin for now. Require system VLC. Revisit when plugin matures |
| Coil3 JVM edge cases | Low | Medium | Coil 3.4.0 is mature. Explicit cache sizing avoids defaults. SvgDecoder confirmed working |
| Commons extraction breaks Android | N/A (deferred) | N/A | Desktop-only approach for Phases 0-3 eliminates this risk |
| Apache Commons Imaging alpha status | Medium | Low | Only using EXIF strip (well-tested path). Fallback: skip EXIF strip for MVP |
| Scope creep across 9 phases | High | High | Ship Phases 0-3 as first PR. Later phases are incremental |
| Compose DnD experimental API changes | Medium | Low | Pin Compose version. AWT `DropTarget` fallback available |
## Acceptance Criteria
### Functional Requirements
- [ ] Images display inline in desktop feed notes with blurhash placeholders
- [ ] Upload works via file picker, drag-drop, and clipboard paste
- [ ] Videos play inline with controls (play/pause, seek, volume)
- [ ] Lightbox opens on image click with zoom and gallery navigation
- [ ] Encrypted media works in DMs (send + receive)
- [ ] Profile gallery shows picture posts
- [ ] Blossom server management UI in settings
- [ ] Audio plays inline in notes
### Non-Functional Requirements
- [ ] Image load time < 2s for typical photos on broadband
- [ ] Upload shows progress in real-time
- [ ] Max 3 active video players (prevent OOM)
- [ ] Disk cache respects 512MB limit
- [ ] EXIF stripped before upload (privacy)
- [ ] Android build never broken (desktop-only approach)
### Quality Gates
- [ ] `./gradlew :desktopApp:compileKotlin` passes
- [ ] `./gradlew :desktopApp:jvmTest` passes (upload unit tests)
- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (no Android breakage)
- [ ] `./gradlew spotlessApply` passes
- [ ] Manual test: full upload → display round-trip on desktop
## Key Files (New or Modified)
### New Files
| File | Phase |
|------|-------|
| `desktopApp/.../service/images/DesktopImageLoaderSetup.kt` | 1 |
| `desktopApp/.../service/images/DesktopImageCacheFactory.kt` | 1 |
| `desktopApp/.../service/images/DesktopBlossomFetcher.kt` | 1 |
| `desktopApp/.../service/images/DesktopBlurHashFetcher.kt` | 1 |
| `desktopApp/.../service/images/DesktopBase64Fetcher.kt` | 1 |
| `desktopApp/.../service/images/SkiaGifDecoder.kt` | 1 |
| `desktopApp/.../model/MediaAspectRatioCache.kt` | 1 |
| `desktopApp/.../service/upload/DesktopBlossomClient.kt` | 2 |
| `desktopApp/.../service/upload/DesktopBlossomAuthHelper.kt` | 2 |
| `desktopApp/.../service/upload/DesktopServerDiscovery.kt` | 2 |
| `desktopApp/.../service/upload/DesktopMediaMetadata.kt` | 2 |
| `desktopApp/.../service/upload/DesktopMediaCompressor.kt` | 2 |
| `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` | 2 |
| `desktopApp/.../service/upload/DesktopMediaUploadTracker.kt` | 2 |
| `desktopApp/.../ui/media/DesktopFilePicker.kt` | 3 |
| `desktopApp/.../ui/media/DragDropHandler.kt` | 3 |
| `desktopApp/.../ui/media/ClipboardPasteHandler.kt` | 3 |
| `desktopApp/.../ui/media/UploadDialog.kt` | 3 |
| `desktopApp/.../ui/media/UploadPreview.kt` | 3 |
| `desktopApp/.../ui/media/DesktopVideoPlayer.kt` | 4 |
| `desktopApp/.../ui/media/VideoControls.kt` | 4 |
| `desktopApp/.../service/media/VlcjPlayerPool.kt` | 4 |
| `desktopApp/.../service/media/VideoThumbnailGenerator.kt` | 4 |
| `desktopApp/.../ui/media/LightboxOverlay.kt` | 5 |
| `desktopApp/.../ui/media/ZoomableImage.kt` | 5 |
| `desktopApp/.../ui/media/GalleryCarousel.kt` | 5 |
| `desktopApp/.../ui/media/AudioPlayer.kt` | 9 |
### Modified Files
| File | Phase | Change |
|------|-------|--------|
| `desktopApp/build.gradle.kts` | 0,1,4 | Add Coil3, kotlinx-coroutines-swing, VLCJ, Commons Imaging deps |
| `gradle/libs.versions.toml` | 0,1,4 | Add new version entries |
| `desktopApp/ui/note/NoteCard.kt` | 1,4,9 | Add inline media rendering |
| `desktopApp/ui/ComposeNoteDialog.kt` | 3 | Add media attach button + upload flow |
| `desktopApp/Main.kt` | 1 | Initialize ImageLoader |
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-03-16-desktop-media-brainstorm.md](docs/brainstorms/2026-03-16-desktop-media-brainstorm.md)
- Key decisions carried forward: Blossom-only, Coil3 for images, VLCJ for video, desktop-only-first approach
### Internal References
- Existing shared UI analysis: `docs/shared-ui-analysis.md`
- PlatformImage expect/actual pattern: `commons/blurhash/PlatformImage.kt`
- Android ImageLoader setup: `amethyst/service/images/ImageLoaderSetup.kt:25-91`
- Android BlossomUploader: `amethyst/service/uploads/blossom/BlossomUploader.kt`
- NoteCard (current text-only): `desktopApp/ui/note/NoteCard.kt:128-132`
- ComposeNoteDialog (current text-only): `desktopApp/ui/ComposeNoteDialog.kt`
- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md`
### External References
- Blossom protocol spec: https://github.com/hzrd149/blossom
- Coil3 Compose Multiplatform: https://coil-kt.github.io/coil/compose/
- Coil3 GIF on JVM limitation: https://github.com/coil-kt/coil/issues/2347
- Coil3 SVG on desktop: https://github.com/coil-kt/coil/issues/2330
- Coil3 Main dispatcher: https://github.com/coil-kt/coil/issues/2009
- VLCJ documentation: https://github.com/caprica/vlcj
- VLCJ GC tutorial: https://capricasoftware.co.uk/tutorials/vlcj/4/garbage-collection
- ComposeVideoPlayer (DirectRendering): https://github.com/rjuszczyk/ComposeVideoPlayer
- Klarity (FFmpeg alternative): https://github.com/numq/Klarity
- vlc-setup Gradle plugin: https://github.com/nickolay-mahozad/vlc-setup
- Apache Commons Imaging: https://commons.apache.org/proper/commons-imaging/
- Compose Desktop DnD docs: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-drag-drop.html
- Skiko Codec API: https://jetbrains.github.io/skiko/skiko/org.jetbrains.skia/-codec/index.html
- HEIC not supported on JVM: https://github.com/JetBrains/skiko/issues/942
## Answered Questions (from original plan)
| # | Question | Answer |
|---|----------|--------|
| 1 | Does `coil3-gif` work on JVM desktop? | **No.** Android-only (`AnimatedImageDecoder` requires API 28+). Use `org.jetbrains.skia.Codec` for GIF decoding |
| 2 | Does `vlc-setup` support arm64 macOS? | **Unconfirmed.** Only Intel Mac (High Sierra) tested. macOS marked "experimental". Require system VLC for now |
| 3 | Does `awtTransferable` deliver files on macOS? | **Yes.** `DataFlavor.javaFileListFlavor` works with Finder on JDK 17+. Include `text/uri-list` fallback for Linux |
| 4 | Is `LinkedHashMap.removeEldestEntry` sufficient? | **No.** Not thread-safe in access-order mode. Use `androidx.collection.LruCache` (already KMP dep) or `ConcurrentHashMap` |
| 5 | Is Coil3 `SvgDecoder` KMP? | **Yes.** Works on JVM via `org.jetbrains.skia.svg.SVGDOM`. Add `SvgDecoder.Factory()` to ImageLoader components |
| 6 | Is `UserAvatar` rendering on desktop? | **Likely failing silently** — no `SingletonImageLoader` configured. Will work after Phase 1 ImageLoader init |
## Remaining Unanswered Questions
1. Does VLCJ DirectRendering achieve acceptable frame rate at 1080p on Apple Silicon?
2. Can `org.jetbrains.skia.Codec` handle all animated WebP variants reliably on JVM?
3. Is VLC 3.0.21 universal binary reliable via JNA on arm64 macOS with JDK 17+?
4. Is there a GPU-accelerated path for VLCJ → Skia Bitmap transfer (avoid CPU copy)?
5. Does `Modifier.dragAndDropTarget` work on Linux Wayland in Compose 1.10.x?
6. Can Klarity handle streaming URLs (HLS, DASH) as VLCJ alternative?
7. Does Coil3 `@ExperimentalCoilApi` `NetworkFetcher` constructor stay stable across versions?
@@ -0,0 +1,800 @@
---
title: "feat: Desktop DM Encrypted Media (NIP-17)"
type: feat
status: completed
date: 2026-03-18
deepened: 2026-03-18
origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md
---
# feat: Desktop DM Encrypted Media (NIP-17)
## Enhancement Summary
**Deepened on:** 2026-03-18
**Sections enhanced:** 5 phases + security + performance + edge cases
**Research sources:** Source code analysis of all 9 key files, Android reference implementation, Blossom protocol research, existing learnings
### Key Improvements
1. Corrected `DesktopBlossomClient` — needs `ByteArray` overload (currently only accepts `File`)
2. `IAccount` interface needs `sendNip17EncryptedFile()` added (not just `DesktopIAccount`)
3. `NIP17Factory.createEncryptedFileNIP17()` already exists — plan incorrectly referenced `createFileNIP17()`
4. `DesktopMediaMetadata.compute()` reads file bytes internally — encrypted upload must avoid double-read
5. Added streaming encryption consideration for large files and memory pressure mitigation
### Critical Corrections from Source Code
- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` parameter — encrypted size, not original
- `DesktopBlossomClient.upload()` only accepts `File`, not `ByteArray` — needs overload or temp file
- `ChatMessageEncryptedFileHeaderEvent.build()` takes `cipher: AESGCM` directly — no manual key/nonce extraction needed
- `key()` and `nonce()` return tag values as parsed types (via `EncryptionKey`/`EncryptionNonce` tags)
---
## Overview
Implement full send + receive encrypted media support in desktop DM chat. Users can attach files via paperclip button or drag-and-drop, which get AES-GCM encrypted before upload to Blossom. Files are sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap (NIP-59). Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay.
This is Phase 6 of the desktop media parity plan.
## Problem Statement / Motivation
Desktop DM chat (`ChatPane.kt`) supports text messages but has no file attachment capability. Android has a complete implementation (`ChatFileUploader` + `ChatFileSender`). Most protocol and service pieces exist on desktop already — this work wires them together with a desktop-native UX.
## Proposed Solution
Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol layer (quartz) and extending desktop services. Three workstreams: upload pipeline, send logic, and receive/display.
(see brainstorm: `docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md`)
---
## Implementation Phases
### Phase A: Encrypted Upload Pipeline
**Goal:** `DesktopUploadOrchestrator` gains `uploadEncrypted()` method.
**Files:**
- `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` — add `uploadEncrypted()`
- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — add `ByteArray` upload overload
- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — no changes needed (already takes hash + size)
- `desktopApp/.../service/upload/DesktopMediaMetadata.kt` — no changes needed
**Implementation:**
```kotlin
// DesktopUploadOrchestrator.kt — new method
suspend fun uploadEncrypted(
file: File,
cipher: AESGCM,
serverBaseUrl: String,
signer: NostrSigner,
): EncryptedUploadResult {
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
// NOTE: DesktopMediaMetadata.compute() reads file bytes internally for SHA256
val metadata = DesktopMediaMetadata.compute(file)
// 2. Read file bytes and encrypt
val plaintext = file.readBytes()
val encrypted = cipher.encrypt(plaintext)
// 3. Compute SHA256 of ENCRYPTED blob (critical: not plaintext)
val encryptedHash = sha256(encrypted).toHexKey()
val encryptedSize = encrypted.size.toLong()
// 4. Create Blossom auth with ENCRYPTED hash and size
val authHeader = DesktopBlossomAuth.createUploadAuth(
hash = encryptedHash,
size = encryptedSize,
alt = "Encrypted upload",
signer = signer,
)
// 5. Upload encrypted blob (needs ByteArray overload on client)
val result = client.upload(
bytes = encrypted,
contentType = "application/octet-stream", // encrypted blob, not original mime
serverBaseUrl = serverBaseUrl,
authHeader = authHeader,
)
return EncryptedUploadResult(
blossom = result,
metadata = metadata, // pre-encryption metadata (dimensions, blurhash, mime)
encryptedHash = encryptedHash,
encryptedSize = encryptedSize.toInt(),
)
}
```
```kotlin
// New data class alongside existing UploadResult
data class EncryptedUploadResult(
val blossom: BlossomUploadResult,
val metadata: MediaMetadata, // original file metadata
val encryptedHash: String, // SHA256 of encrypted blob
val encryptedSize: Int, // size of encrypted blob
)
```
```kotlin
// DesktopBlossomClient.kt — add ByteArray overload
suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val requestBuilder = Request.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers["X-Reason"] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
}
}
```
### Research Insights — Phase A
**Critical gotchas (from Blossom protocol research + source code analysis):**
| Issue | Detail | Solution |
|-------|--------|----------|
| Hash mismatch | `X-SHA-256` header must match encrypted blob hash, not plaintext | Compute SHA256 after `cipher.encrypt()` |
| Content-Type | Upload encrypted blob as `application/octet-stream`, not original MIME | Server stores opaque blob |
| Auth size | `DesktopBlossomAuth.createUploadAuth(size=)` must be encrypted size | Pass `encrypted.size.toLong()` |
| Double file read | `DesktopMediaMetadata.compute()` calls `file.readBytes()` internally | Acceptable — metadata computation is separate from encryption read |
| Memory pressure | `file.readBytes()` + `cipher.encrypt()` = 2x file size in memory | For files <50MB this is fine; for larger files consider streaming (future) |
**Security considerations:**
- Generate fresh `AESGCM()` per file — never reuse key/nonce pairs (AES-GCM nonce reuse completely breaks confidentiality)
- Clear `plaintext` ByteArray after encryption (`plaintext.fill(0)`) to minimize exposure window
- Encrypted blob content type should be `application/octet-stream` to avoid leaking file type to Blossom server
---
### Phase B: DM File Attach UI in ChatPane
**Goal:** Paperclip button, thumbnail row, drag-and-drop in `ChatPane.kt`.
**Files:**
- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable and `ChatPane()` for drag-drop
**Implementation:**
Modify `MessageInput()` (currently lines 527-627) — add parameters and UI elements:
```kotlin
// Updated MessageInput signature
@Composable
private fun MessageInput(
messageText: String,
isNip17: Boolean,
requiresNip17: Boolean,
canSend: Boolean,
attachedFiles: List<File>, // NEW
onMessageChange: (String) -> Unit,
onToggleNip17: () -> Unit,
onAttachFiles: (List<File>) -> Unit, // NEW
onRemoveFile: (Int) -> Unit, // NEW
onSend: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
// Attachment thumbnail row (above text input)
if (attachedFiles.isNotEmpty()) {
AttachmentRow(
files = attachedFiles,
isEncrypted = isNip17,
onRemove = onRemoveFile,
)
Spacer(Modifier.height(4.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Paperclip attach button (only in NIP-17 mode)
if (isNip17) {
IconButton(
onClick = { /* open JFileChooser */ },
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.Default.AttachFile,
contentDescription = "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Existing OutlinedTextField...
// Existing Send button...
}
// Existing NIP-17 indicator...
}
}
```
```kotlin
// AttachmentRow composable
@Composable
private fun AttachmentRow(
files: List<File>,
isEncrypted: Boolean,
onRemove: (Int) -> Unit,
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
) {
itemsIndexed(files) { index, file ->
Box(modifier = Modifier.size(64.dp)) {
// Thumbnail (image preview or file icon)
AttachmentThumbnail(file)
// Remove button (top-right)
IconButton(
onClick = { onRemove(index) },
modifier = Modifier.align(Alignment.TopEnd).size(18.dp),
) {
Icon(Icons.Default.Close, "Remove", Modifier.size(12.dp))
}
// Lock icon overlay (bottom-end, only when encrypted)
if (isEncrypted) {
Icon(
Icons.Default.Lock,
contentDescription = "Encrypted",
modifier = Modifier
.align(Alignment.BottomEnd)
.size(16.dp)
.background(
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
RoundedCornerShape(4.dp),
)
.padding(2.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
```
**File picker (JFileChooser pattern from ComposeNoteDialog):**
```kotlin
// File picker helper — runs on AWT thread
private fun openFilePicker(onFilesSelected: (List<File>) -> Unit) {
val chooser = JFileChooser().apply {
isMultiSelectionEnabled = true
fileFilter = FileNameExtensionFilter(
"Media files",
"jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov",
"mp3", "ogg", "wav", "flac", "aac",
)
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
onFilesSelected(chooser.selectedFiles.toList())
}
}
```
**Drag-and-drop on ChatPane (wrapping the Column):**
```kotlin
// In ChatPane() — wrap the main Column with drag-drop
var isDragOver by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxSize()
.onExternalDrag(
onDragStart = { isDragOver = true },
onDragExit = { isDragOver = false },
onDrop = { state ->
isDragOver = false
val files = state.dragData
.let { it as? DragData.FilesList }
?.readFiles()
?.mapNotNull { uri -> File(URI(uri)) }
?: emptyList()
// Add to attachedFiles state
attachedFiles.addAll(files)
},
)
.then(
if (isDragOver) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
} else {
Modifier
}
),
) {
// ... existing ChatPane content
}
```
### Research Insights — Phase B
**Compose best practices applied:**
| Pattern | Recommendation | Rationale |
|---------|---------------|-----------|
| State location | `attachedFiles` as `mutableStateListOf<File>()` in `ChatPane`, not `MessageInput` | State hoisted to parent that also handles send/upload logic |
| File picker thread | `JFileChooser` must run on EDT; use `withContext(Dispatchers.Main)` or `SwingUtilities.invokeLater` | AWT file dialogs block; Compose coroutines must not be blocked |
| Drag-drop modifier | `Modifier.onExternalDrag` from `compose.ui` | Already used in ComposeNoteDialog — consistent pattern |
| Thumbnail rendering | Use `ImageIO.read()` for image thumbnails; generic icon for audio/video | Avoid loading full-resolution images; scale down for 64dp thumbnails |
| `canSend` update | `canSend` should also be true when `attachedFiles.isNotEmpty()` even if `messageText.isEmpty()` | Allow sending file-only messages (no text required) |
**Desktop UX considerations:**
- Keyboard shortcut: Cmd+V / Ctrl+V paste should also add clipboard images to attachments (future enhancement)
- Tooltip on disabled attach button (NIP-04 mode): "Switch to NIP-17 to send files"
- Maximum attachment count: limit to 10 files to prevent UI overflow
- File size validation: warn on files >50MB before attempting upload
---
### Phase C: Send Encrypted File Event
**Goal:** Build and dispatch `ChatMessageEncryptedFileHeaderEvent` (kind 15) from upload results.
**Files:**
- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `ChatPane()` scope
- `desktopApp/.../model/DesktopIAccount.kt` — add `sendNip17EncryptedFile()`
- `commons/.../model/IAccount.kt` — add interface method
**Implementation:**
```kotlin
// IAccount.kt — add to interface (commons/commonMain)
/** Send a NIP-17 gift-wrapped encrypted file header */
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
```
```kotlin
// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly)
override suspend fun sendNip17EncryptedFile(
template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
) {
if (!isWriteable()) return
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
// Optimistic local add — use the inner event
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
// Collect wraps with target relays and send
val batch = result.wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays = if (recipientKey != null) {
val dmRelays = localCache.getOrCreateUser(recipientKey)
.dmInboxRelays()?.toSet()
dmRelays?.ifEmpty { null } ?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
wrap to targetRelays
}
scope.launch { dmSendTracker.sendBatch(batch) }
}
```
```kotlin
// ChatPane.kt — send handler for encrypted files
// In ChatPane composable scope, after upload completes:
private suspend fun sendEncryptedFiles(
uploads: List<Pair<EncryptedUploadResult, AESGCM>>,
roomKey: ChatroomKey,
account: IAccount,
cacheProvider: ICacheProvider,
) {
val recipients = roomKey.users.map { cacheProvider.getOrCreateUser(it).toPTag() }
for ((result, cipher) in uploads) {
val template = ChatMessageEncryptedFileHeaderEvent.build(
to = recipients,
url = result.blossom.url,
cipher = cipher, // passes algo, key, nonce automatically
mimeType = result.metadata.mimeType,
hash = result.encryptedHash, // hash of encrypted blob
size = result.encryptedSize,
dimension = result.metadata.width?.let { w ->
result.metadata.height?.let { h -> DimensionTag(w, h) }
},
blurhash = result.metadata.blurhash,
originalHash = result.metadata.sha256, // hash of original plaintext
)
account.sendNip17EncryptedFile(template)
}
}
```
**Full send flow in ChatPane (upload + send):**
```kotlin
// In ChatPane composable — triggered by Send button when attachedFiles.isNotEmpty()
scope.launch {
val orchestrator = DesktopUploadOrchestrator()
val server = /* user's default Blossom server from kind 10063 */
val uploads = mutableListOf<Pair<EncryptedUploadResult, AESGCM>>()
for (file in attachedFiles) {
val cipher = AESGCM() // fresh cipher per file
try {
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
uploads.add(result to cipher)
} catch (e: Exception) {
// Show error, keep remaining files for retry
println("Upload failed for ${file.name}: ${e.message}")
}
}
if (uploads.isNotEmpty()) {
sendEncryptedFiles(uploads, roomKey, account, cacheProvider)
attachedFiles.clear()
// Also send text message if present
if (messageState.canSend) {
messageState.send()
messageState.clear()
}
}
}
```
### Research Insights — Phase C
**Correctness checks from source code:**
| Verified | Detail |
|----------|--------|
| `NIP17Factory.createEncryptedFileNIP17()` | Exists at `NIP17Factory.kt:86-97` — takes `EventTemplate<ChatMessageEncryptedFileHeaderEvent>` |
| `ChatMessageEncryptedFileHeaderEvent.build()` | Takes `cipher: AESGCM` directly — auto-extracts algo/key/nonce via `encryptionAlgo(cipher.name())`, `encryptionKey(cipher.keyBytes)`, `encryptionNonce(cipher.nonce)` |
| Android pattern | `Account.sendNip17EncryptedFile()` at line 1612 calls `NIP17Factory().createEncryptedFileNIP17(template, signer)` then `broadcastPrivately(wraps)` |
| Desktop pattern | `DesktopIAccount.sendNip17PrivateMessage()` at line 128 — same structure, replace `createMessageNIP17` with `createEncryptedFileNIP17` |
**Concurrency considerations:**
- Upload files sequentially (not parallel) to avoid memory pressure from multiple concurrent encryptions
- Use `supervisorScope` if you want one failed upload to not cancel others
- Send events can be parallelized (each is independent after upload)
**Interface change impact:**
- Adding `sendNip17EncryptedFile` to `IAccount` requires implementation in Android's `Account.kt` too — but it already has it as a non-override method. Just add `override` keyword.
---
### Phase D: Receive & Display Encrypted Media
**Goal:** Encrypted media in received DMs renders inline with lock icon.
**Files:**
- `desktopApp/.../ui/chats/ChatPane.kt` — enhance `ChatFileAttachment()` (line 442)
- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists, enhance with caching
**Current state:** `ChatFileAttachment` is called at line 442 but its implementation is minimal or placeholder. The event is already detected as `ChatMessageEncryptedFileHeaderEvent`.
**Implementation:**
```kotlin
@Composable
private fun ChatFileAttachment(event: ChatMessageEncryptedFileHeaderEvent) {
// Parse cipher params from event tags
val url = event.url()
val keyBytes = event.key() // returns ByteArray? (parsed from EncryptionKey tag)
val nonceBytes = event.nonce() // returns ByteArray? (parsed from EncryptionNonce tag)
val mimeType = event.mimeType()
val blurhashStr = event.blurhash()
if (url.isNullOrEmpty() || keyBytes == null || nonceBytes == null) {
// Missing encryption params — show error
EncryptedFileError("Missing encryption data")
return
}
// Async download + decrypt with proper state management
var decryptionState by remember(event.id) {
mutableStateOf<DecryptionState>(DecryptionState.Loading)
}
LaunchedEffect(event.id) {
decryptionState = try {
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonceBytes)
DecryptionState.Success(bytes)
} catch (e: Exception) {
DecryptionState.Error(e.message ?: "Decryption failed")
}
}
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
) {
when (val state = decryptionState) {
is DecryptionState.Loading -> {
// Blurhash placeholder or shimmer
if (blurhashStr != null) {
BlurhashPlaceholder(
blurhash = blurhashStr,
modifier = Modifier.fillMaxSize(),
)
} else {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center).size(24.dp),
)
}
}
is DecryptionState.Success -> {
DecryptedMediaContent(
bytes = state.bytes,
mimeType = mimeType,
modifier = Modifier.fillMaxWidth(),
)
}
is DecryptionState.Error -> {
EncryptedFileError(state.message)
}
}
// Lock icon overlay (always visible)
Icon(
Icons.Default.Lock,
contentDescription = "End-to-end encrypted",
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.size(20.dp)
.background(
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
RoundedCornerShape(4.dp),
)
.padding(2.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
private sealed class DecryptionState {
data object Loading : DecryptionState()
data class Success(val bytes: ByteArray) : DecryptionState()
data class Error(val message: String) : DecryptionState()
}
```
```kotlin
// DecryptedMediaContent — render based on MIME type
@Composable
private fun DecryptedMediaContent(
bytes: ByteArray,
mimeType: String?,
modifier: Modifier = Modifier,
) {
when {
mimeType?.startsWith("image/") == true -> {
val bitmap = remember(bytes) {
org.jetbrains.skia.Image.makeFromEncoded(bytes)
.toComposeImageBitmap()
}
Image(
bitmap = bitmap,
contentDescription = "Encrypted image",
contentScale = ContentScale.Fit,
modifier = modifier,
)
}
mimeType?.startsWith("video/") == true -> {
// Show video thumbnail or play button
// Full video playback requires writing decrypted bytes to temp file for VLC
VideoFilePlaceholder(bytes.size, modifier)
}
mimeType?.startsWith("audio/") == true -> {
AudioFilePlaceholder(bytes.size, modifier)
}
else -> {
GenericFilePlaceholder(mimeType, bytes.size, modifier)
}
}
}
```
### Research Insights — Phase D
**Compose state management:**
- Use `LaunchedEffect(event.id)` not `produceState` — better control over loading/error states via sealed class
- `remember(event.id)` keys state to the event, preventing re-download on recomposition
- `remember(bytes)` for Skia bitmap conversion prevents recreating bitmap every recomposition
**Performance considerations:**
| Concern | Mitigation |
|---------|------------|
| Re-download on scroll | Add in-memory LRU cache to `EncryptedMediaService` keyed by URL |
| Large decrypted images in memory | Scale down to max display size (300dp) before caching |
| Bitmap creation from bytes | `org.jetbrains.skia.Image.makeFromEncoded()` is efficient for JVM |
| Video/audio playback | Requires writing decrypted bytes to temp file (VLC needs file path). Use `File.createTempFile()` with `.deleteOnExit()` |
**Add caching to EncryptedMediaService:**
```kotlin
object EncryptedMediaService {
private val httpClient = OkHttpClient()
private val cache = LruCache<String, ByteArray>(maxSize = 20) // ~20 decrypted files
suspend fun downloadAndDecrypt(
url: String,
keyBytes: ByteArray,
nonce: ByteArray,
): ByteArray {
cache.get(url)?.let { return it }
return withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
val encryptedBytes = response.use {
if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}")
it.body.bytes()
}
val cipher = AESGCM(keyBytes, nonce)
val decrypted = cipher.decrypt(encryptedBytes)
cache.put(url, decrypted)
decrypted
}
}
}
```
**Security note:** Cached decrypted bytes are in JVM heap memory. This is acceptable for a desktop app (no shared memory concerns). Consider cache eviction on app minimize if paranoid.
---
### Phase E: Error Handling & Edge Cases
**Files:** Across all modified files above.
| Scenario | Handling | Implementation |
|----------|----------|----------------|
| Upload fails mid-way | Show error snackbar, keep file in attachment row for retry | Catch in upload loop, skip failed file, continue others |
| Network disconnect during upload | Catch `IOException`, show "Upload failed — check connection" | OkHttp throws on network failure |
| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | `DecryptionState.Error` sealed class variant |
| Blossom server unreachable | Fallback to next server in user's kind 10063 list | Query `BlossomServersEvent` for alternatives |
| Large file (>10MB) | Show progress indicator during encrypt + upload | Extend `DesktopUploadTracker` for encrypted uploads |
| Unsupported MIME type | Show generic file icon with filename + size | `GenericFilePlaceholder` composable |
| No Blossom servers configured | Disable attach button, show tooltip | Check server list before enabling button |
| NIP-04 mode active | Attach button hidden (encrypted files are NIP-17 only) | `if (isNip17)` guard on paperclip button |
| Corrupt encrypted blob | `AESGCM.decrypt()` throws `AEADBadTagException` | Catch specifically, show "File corrupted or tampered" |
| Duplicate upload (same file) | Each send generates new cipher — different encrypted blob | Intentional: no deduplication for privacy |
| Rapid send taps | Disable send button during upload/send | `isUploading` state flag |
### Research Insights — Phase E
**Error hierarchy (from AESGCM source):**
- `AESGCM.decrypt()` uses JCE `Cipher` on JVM — throws `AEADBadTagException` for wrong key (not generic exception)
- `AESGCM.decryptOrNull()` exists — returns null instead of throwing. Prefer this for UI code.
**Security edge cases:**
- Never log encryption keys, nonces, or decrypted content
- Temp files for video playback must be deleted after use (`deleteOnExit()` + explicit delete on composable disposal)
- Don't show detailed error messages that could leak cipher state ("wrong key" is fine, "key was X but expected Y" is not)
---
## Technical Considerations
### Security
- New `AESGCM()` cipher per file (random key + nonce) — never reuse
- Encrypt before hashing — Blossom auth scoped to encrypted blob
- Plaintext never leaves device unencrypted
- GiftWrap ensures only recipients can see the kind 15 event
- Zero `plaintext` ByteArray after encryption to minimize exposure window
- Upload content type is `application/octet-stream` (doesn't leak file type to server)
- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay attacks
### Performance
- Encryption runs on `Dispatchers.IO` (non-blocking)
- Sequential file upload (not parallel) to limit memory to 2x single file size
- In-memory LRU cache for decrypted media (20 entries) avoids re-downloading
- `org.jetbrains.skia.Image.makeFromEncoded()` for efficient bitmap creation
- Large files (>50MB): warn user before upload; consider streaming in future
### Architecture
- No new modules — extends existing desktop services
- Protocol layer (quartz) unchanged — fully reuses existing events/ciphers
- Follows Android patterns for consistency across platforms
- `IAccount` interface gains one new method; Android already has implementation (add `override`)
---
## Acceptance Criteria
- [x] Paperclip attach button visible in DM chat input (NIP-17 mode only)
- [x] File picker opens, supports image/video/audio selection
- [x] Selected files show as thumbnails above input with X to remove
- [x] Drag-and-drop files onto chat area adds to attachments (visual drop indicator)
- [x] Send encrypts files with AES-GCM before upload to Blossom
- [x] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap
- [x] Lock icon overlay visible on attachment thumbnails before send
- [x] Received encrypted media downloads, decrypts, displays inline
- [x] Lock icon overlay on received encrypted media in chat bubbles
- [x] Wrong key / failed decryption shows error state, no crash
- [x] Upload progress indicator during encrypt + upload
- [x] No attach button when in NIP-04 mode
- [x] `canSend` true when files attached (even without text)
- [x] Send button disabled during active upload
## Test Plan (from Phase 6 testing plan)
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail appears above input with lock indicator |
| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap sent |
| 6.3 | Receive encrypted | Receive DM with encrypted file (from Android) | File downloads, decrypts, displays in bubble with lock icon |
| 6.4 | Wrong key | View encrypted media where key doesn't match | "Could not decrypt" placeholder, no crash |
| 6.5 | Drag-drop attach | Drag image onto DM chat | File appears in attachment row with lock icon |
| 6.6 | Multiple files | Attach 3 files → send | All 3 upload encrypted, each gets own kind 15 event |
| 6.7 | Large file | Attach 15MB image → send | Progress shown, upload succeeds |
| 6.8 | NIP-04 mode | Toggle to NIP-04 → check attach button | Attach button hidden/disabled |
## Dependencies & Risks
| Dependency | Risk | Mitigation |
|-----------|------|------------|
| Blossom server availability | Upload fails | Retry + fallback to alternate servers from kind 10063 |
| VLC for video playback | Encrypted video won't play without VLC | Show "Download" button for video; image display works regardless |
| Android client for cross-platform test | Can't verify interop | Use Android emulator or second account |
| `DesktopBlossomClient` ByteArray overload | New method needed | Simple addition — uses OkHttp `ByteArray.toRequestBody()` |
| `IAccount` interface change | Requires Android-side `override` keyword | Android already has the method, just not `override` |
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md](docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md) — Key decisions: inline attach button UX, drag-drop support, lock icon overlay, full send+receive scope
### Internal References (verified from source code)
- Android `ChatFileUploader.justUploadNIP17()`: `amethyst/.../upload/ChatFileUploader.kt:39-80`
- Android `ChatFileSender.sendNIP17()`: `amethyst/.../upload/ChatFileSender.kt:46-71`
- Android `Account.sendNip17EncryptedFile()`: `amethyst/.../model/Account.kt:1612-1617`
- Desktop `ChatPane.kt`: `desktopApp/.../ui/chats/ChatPane.kt` (628 lines)
- Desktop `DesktopUploadOrchestrator`: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` (78 lines)
- Desktop `DesktopBlossomClient`: `desktopApp/.../service/upload/DesktopBlossomClient.kt` (74 lines)
- Desktop `DesktopBlossomAuth`: `desktopApp/.../service/upload/DesktopBlossomAuth.kt` (43 lines)
- Desktop `DesktopMediaMetadata`: `desktopApp/.../service/upload/DesktopMediaMetadata.kt` (89 lines)
- Desktop `EncryptedMediaService`: `desktopApp/.../service/media/EncryptedMediaService.kt` (57 lines)
- Desktop `DesktopIAccount`: `desktopApp/.../model/DesktopIAccount.kt`
- Desktop `ComposeNoteDialog` (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt`
- Commons `IAccount` interface: `commons/.../model/IAccount.kt:106-113`
- Quartz `ChatMessageEncryptedFileHeaderEvent.build()`: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt:80-110`
- Quartz `NIP17Factory.createEncryptedFileNIP17()`: `quartz/.../nip17Dm/NIP17Factory.kt:86-97`
- Quartz `AESGCM`: `quartz/.../utils/ciphers/AESGCM.kt`
- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md`
### Gotchas (from learnings research + source verification)
- Hash encrypted blob, not plaintext, for Blossom auth `x` tag and `X-SHA-256` header
- New AESGCM cipher per file — never reuse key/nonce pair (nonce reuse breaks AES-GCM completely)
- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay
- NIP-04 does not support encrypted file headers — hide attach button in NIP-04 mode
- `DesktopBlossomClient.upload()` only accepts `File` — needs `ByteArray` overload for encrypted blobs
- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` — must be encrypted blob size
- Content-Type for encrypted upload must be `application/octet-stream`, not original MIME
- `AESGCM.decryptOrNull()` exists — prefer over `decrypt()` for UI code (no exception on wrong key)
@@ -0,0 +1,343 @@
---
title: "feat: Stacked messages layout in deck columns"
type: feat
status: completed
date: 2026-03-19
deepened: 2026-03-19
origin: docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md
---
# feat: Stacked Messages Layout in Deck Columns
## Enhancement Summary
**Deepened on:** 2026-03-19
**Files to change:** 4
**Approach:** Add `compactMode` flag, conditional layout in `DesktopMessagesScreen`
### Key Implementation Details
1. `ConversationListPane` width is hardcoded at line 122 (`Modifier.width(280.dp)`) — remove it, let caller control width
2. `ChatPane` header (lines 211-231) uses `ChatroomHeader`/`GroupChatroomHeader` — wrap with `Row` adding back arrow
3. `DesktopMessagesScreen` already has `selectedRoom` state and `clearSelection()` — stacked nav is pure layout change
4. Keyboard Escape handling already at line 102 calls `listState.clearSelection()` — works as-is for back nav
---
## Overview
Replace the side-by-side split-pane Messages layout with stacked navigation in deck columns. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the current split layout.
## Problem Statement
In multi-deck mode, columns are 350-400dp wide. The current layout allocates 280dp to `ConversationListPane` (line 122) and the remaining 70-120dp to `ChatPane` — unusable.
(see brainstorm: `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`)
---
## Step 1: Make ConversationListPane width flexible
**File:** `desktopApp/.../ui/chats/ConversationListPane.kt` (line 119-123)
**Current code:**
```kotlin
Column(
modifier =
modifier
.width(280.dp) // ← hardcoded, breaks compact mode
.fillMaxHeight()
```
**Change:** Remove the hardcoded width from the composable. The caller controls width via the `modifier` parameter.
```kotlin
Column(
modifier =
modifier
.fillMaxHeight()
```
**Call sites:**
- Split mode (DesktopMessagesScreen): passes no modifier → add `Modifier.width(280.dp)` at call site
- Compact mode: passes `Modifier.fillMaxWidth()` → uses full column width
### Research Insights
- `ConversationListPane` already accepts `modifier: Modifier = Modifier` (line 95) — just unused for width
- The keyboard nav (`onPreviewKeyEvent` at line 126) and `LazyColumn` (line 243) work at any width
- `ConversationCard` (line 268) uses `Modifier.fillMaxWidth()` — adapts automatically
---
## Step 2: Add `onBack` to ChatPane header
**File:** `desktopApp/.../ui/chats/ChatPane.kt` (lines 136-144, 211-231)
**Current signature:**
```kotlin
fun ChatPane(
roomKey: ChatroomKey,
account: IAccount,
cacheProvider: ICacheProvider,
feedViewModel: ChatroomFeedViewModel,
messageState: ChatNewMessageState,
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
onNavigateToProfile: (String) -> Unit = {},
modifier: Modifier = Modifier,
)
```
**Add:** `onBack: (() -> Unit)? = null` parameter.
**Current header (lines 211-231):**
```kotlin
// Header
if (isGroup) {
GroupChatroomHeader(
users = users,
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
)
} else {
users.firstOrNull()?.let { user ->
ChatroomHeader(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
} ?: run {
Text(
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(10.dp),
)
}
}
```
**New header:** Wrap in a `Row` with conditional back arrow:
```kotlin
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
if (onBack != null) {
IconButton(
onClick = onBack,
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to conversations",
)
}
}
Box(modifier = Modifier.weight(1f)) {
// Existing header content (ChatroomHeader / GroupChatroomHeader / fallback)
}
}
```
### Research Insights
- `Icons.AutoMirrored.Filled.ArrowBack` is already available in material-icons-extended
- `ChatroomHeader` and `GroupChatroomHeader` are shared composables from commons — don't modify them
- The `Row` wrapper doesn't affect the existing divider at line 233 (`HorizontalDivider()`)
---
## Step 3: Refactor DesktopMessagesScreen layout
**File:** `desktopApp/.../ui/chats/DesktopMessagesScreen.kt`
**Current:** Single `Row` layout (lines 92-168) with `ConversationListPane` + `VerticalDivider` + `ChatPane`/`EmptyState`.
**Change:** Add `compactMode: Boolean = false` parameter. Extract existing Row into a private `SplitMessagesContent` composable. Add a new `CompactMessagesContent` for stacked mode.
```kotlin
@Composable
fun DesktopMessagesScreen(
account: IAccount,
cacheProvider: ICacheProvider,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
compactMode: Boolean = false, // NEW
onNavigateToProfile: (String) -> Unit = {},
) {
val scope = rememberCoroutineScope()
val listState = remember(account) {
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
}
val selectedRoom by listState.selectedRoom.collectAsState()
val listFocusRequester = remember { FocusRequester() }
var showNewDmDialog by remember { mutableStateOf(false) }
// Keyboard shortcuts (shared between modes)
val keyHandler = Modifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
when {
event.key == Key.Escape -> {
listState.clearSelection()
true
}
event.key == Key.N && isModifier && event.isShiftPressed -> {
showNewDmDialog = true
true
}
else -> false
}
}
if (compactMode) {
CompactMessagesContent(
selectedRoom = selectedRoom,
listState = listState,
account = account,
cacheProvider = cacheProvider,
scope = scope,
onNavigateToProfile = onNavigateToProfile,
listFocusRequester = listFocusRequester,
showNewDmDialog = showNewDmDialog,
onShowNewDm = { showNewDmDialog = true },
keyHandler = keyHandler,
)
} else {
SplitMessagesContent(
selectedRoom = selectedRoom,
listState = listState,
account = account,
cacheProvider = cacheProvider,
scope = scope,
onNavigateToProfile = onNavigateToProfile,
listFocusRequester = listFocusRequester,
keyHandler = keyHandler,
onShowNewDm = { showNewDmDialog = true },
)
}
// New DM dialog (shared)
if (showNewDmDialog) { /* existing NewDmDialog code */ }
}
```
**CompactMessagesContent:**
```kotlin
@Composable
private fun CompactMessagesContent(
selectedRoom: ChatroomKey?,
listState: ChatroomListState,
account: IAccount,
cacheProvider: ICacheProvider,
scope: CoroutineScope,
onNavigateToProfile: (String) -> Unit,
listFocusRequester: FocusRequester,
showNewDmDialog: Boolean,
onShowNewDm: () -> Unit,
keyHandler: Modifier,
) {
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
val currentRoom = selectedRoom
if (currentRoom != null) {
// Full-width chat with back arrow
val feedViewModel = remember(currentRoom) {
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
}
val messageState = remember(currentRoom) {
ChatNewMessageState(account, cacheProvider, scope)
}
val broadcastStatus = if (account is DesktopIAccount) {
account.dmSendTracker.status.collectAsState().value
} else DmBroadcastStatus.Idle
ChatPane(
roomKey = currentRoom,
account = account,
cacheProvider = cacheProvider,
feedViewModel = feedViewModel,
messageState = messageState,
dmBroadcastStatus = broadcastStatus,
onNavigateToProfile = onNavigateToProfile,
onBack = { listState.clearSelection() },
)
} else {
// Full-width contact list
ConversationListPane(
state = listState,
selectedRoom = selectedRoom,
onConversationSelected = { listState.selectRoom(it) },
onNewConversation = onShowNewDm,
focusRequester = listFocusRequester,
modifier = Modifier.fillMaxSize(),
)
}
}
}
```
**SplitMessagesContent:** Extract existing `Row` code verbatim from current `DesktopMessagesScreen`, adding `Modifier.width(280.dp)` to the `ConversationListPane` call.
---
## Step 4: Wire compactMode from deck
**File:** `desktopApp/.../ui/deck/DeckColumnContainer.kt` (line 206-214)
```kotlin
DeckColumnType.Messages -> {
DesktopMessagesScreen(
account = iAccount,
cacheProvider = localCache,
relayManager = relayManager,
localCache = localCache,
compactMode = true, // ← ADD THIS
onNavigateToProfile = onNavigateToProfile,
)
}
```
`SinglePaneLayout` — no change needed, `compactMode` defaults to `false`.
---
## Edge Cases
| Scenario | Behavior | Verified by |
|----------|----------|-------------|
| Escape in chat | `clearSelection()` → back to list | Existing keyboard handler (line 102) |
| Escape in list | No-op (already no selection) | Same handler, `clearSelection()` on null is safe |
| New DM dialog in compact chat | Dialog opens over chat, selecting user switches to that chat | `showNewDmDialog` is shared state |
| Receiving DM while viewing list | Chatroom list updates via 2s polling | `ChatroomListState.refreshRooms()` |
| Receiving DM while in chat | Messages appear real-time via `ChatroomFeedViewModel` | No change needed |
| Back arrow + drag-drop | Drag-drop zone is on the ChatPane Column, unaffected by back arrow Row | Separate modifier chain |
---
## Acceptance Criteria
- [x] Deck Messages column shows full-width contact list (no split)
- [x] Clicking conversation navigates to full-width chat view
- [x] Back arrow visible in chat header (compact mode only)
- [x] Clicking back arrow returns to contact list
- [x] Escape key still returns to contact list
- [x] Single-pane mode unchanged (split layout preserved)
- [x] Keyboard navigation (up/down/enter) still works in contact list
- [x] New DM dialog still works in both modes
- [x] ConversationListPane uses full width in compact mode
## Files Changed
| File | Change | Lines affected |
|------|--------|---------------|
| `ConversationListPane.kt` | Remove hardcoded `width(280.dp)` | Line 122 |
| `ChatPane.kt` | Add `onBack` param, wrap header in Row with back arrow | Lines 136-144, 211-231 |
| `DesktopMessagesScreen.kt` | Add `compactMode`, extract `SplitMessagesContent`/`CompactMessagesContent` | Major refactor |
| `DeckColumnContainer.kt` | Pass `compactMode = true` | Line ~208 |
## Sources
- **Brainstorm:** `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`
- `DesktopMessagesScreen.kt:75-213` — current split-pane layout
- `ChatPane.kt:136-144` — current signature; `211-231` — header section
- `ConversationListPane.kt:119-122` — hardcoded width; `95` — modifier param
- `DeckColumnContainer.kt:206-214` — Messages deck routing
+4
View File
@@ -60,6 +60,8 @@ unifiedpush = "3.0.10"
vico-charts-compose = "3.0.3"
zelory = "3.0.1"
zoomable = "2.11.1"
vlcj = "4.8.3"
commonsImaging = "1.0.0-alpha5"
zxing = "3.5.4"
zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.5.1"
@@ -121,6 +123,8 @@ coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil"
coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" }
coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }