diff --git a/.gitignore b/.gitignore index 945f2e02a..b5af05773 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 8f062abf1..70735fdd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1609,7 +1609,7 @@ class Account( client.send(newEvent, outboxRelays.flow.value + destinationRelays) } - suspend fun sendNip17EncryptedFile(template: EventTemplate) { + override suspend fun sendNip17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer) diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 5ef8dfde6..e28e942fd 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Imagen de grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Icono del transmisor Autor desconocido @@ -100,10 +103,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Imagen de vista previa para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se ha encontrado la descripción "Sobre nosotros…" @@ -118,15 +123,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Fuente de mensajes privados Fuente de chat público Fuente global Fuente de búsqueda + Buscar y añadir usuario + Añadir un usuario Añadir transmisor Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -140,6 +151,7 @@ Pronombres Dirección LN Dirección LN (antigua) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería La descarga del vídeo ha comenzado… @@ -148,11 +160,14 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Cargar imagen + Cargar archivo Hacer una foto Grabar un video Grabar un mensaje Grabar un mensaje Haz clic y mantén pulsado para grabar un mensaje + Volver a grabar + Grabación Cargando… El usuario no tiene una configuración de dirección Lightning para recibir sats "responde aquí… " @@ -353,6 +368,11 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Icono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores Servicio de conexión a monedero Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a monedero diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 1639273c3..c517d6987 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Foto de grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Ícono de relé Autor desconocido @@ -99,10 +102,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Vista previa de imagen de tarjeta para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se encontró la descripción "Quiénes somos…" @@ -117,15 +122,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Feed de mensajes privados Feed del chat público Feed global Feed de búsqueda + Buscar y agregar usuario + Agregar un usuario Agregar un relé Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -139,6 +150,7 @@ Pronombres Dirección de Lightning URL de Lightning (obsoleta) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería Comenzó la descarga del video… @@ -147,10 +159,13 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Subir imagen + Subir archivo Tomar una foto Grabar un mensaje Grabar un mensaje Haz clic y mantén presionado para grabar un mensaje + Volver a grabar + Grabación Subiendo… El usuario no tiene configurada una dirección de Lightning para recibir sats "responde aquí… " @@ -349,6 +364,11 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Ícono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores Servicio de conexión a billetera Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a billetera diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index ce5ac0327..bba54dbf6 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -15,12 +15,15 @@ No se pudo desencriptar el mensaje Imagen del grupo Contenido explícito + aviso_relé + publicación_duplicada Spam El número de eventos de spam procedentes de este relé Suplantación de identidad Comportamiento ilegal Otro Acoso + Violencia Desconocido Ícono de relé Autor desconocido @@ -99,10 +102,12 @@ Agregar a publicación "Error al analizar la vista previa de %1$s : %2$s" "Vista previa de imagen de tarjeta para %1$s" + Artículo Nuevo canal Nombre del canal Mi grupo genial URL de imagen + Url de imagen (opcional) Descripción No se encontró la descripción "Quiénes somos…" @@ -117,15 +122,21 @@ Dirección del relé Publicaciones Bytes + Error Errores + Porcentaje de conexiones exitosas al relé El número de errores de conexión en esta sesión Tus noticias Feed de mensajes privados Feed del chat público Feed global Feed de búsqueda + Buscar y agregar usuario + Agregar un usuario Agregar un relé Nombre + Nombre (para @etiquetar) + Mi nombre de @etiqueta Nombre para mostrar Mi nombre para mostrar Avestruz Maravillosa @@ -139,6 +150,7 @@ Pronombres Dirección de Lightning URL de Lightning (obsoleta) + Guardar en el teléfono Guardar en la galería Imagen guardada en la galería Comenzó la descarga del video… @@ -147,10 +159,13 @@ Video guardado en la galería de videos del teléfono Error al guardar el video Subir imagen + Subir archivo Tomar una foto Grabar un mensaje Grabar un mensaje Haz clic y mantén presionado para grabar un mensaje + Volver a grabar + Grabación Subiendo… El usuario no tiene configurada una dirección de Lightning para recibir sats "responde aquí… " @@ -349,6 +364,11 @@ Agregar a marcadores públicos Eliminar de marcadores privados Eliminar de marcadores públicos + Listas de marcadores + Ícono para la lista de marcadores + Nueva lista de marcadores + Metadatos de lista de marcadores + Clonar lista de marcadores Servicio de conexión a billetera Autoriza a un secreto de Nostr para pagar zaps sin salir de la app. Mantén el secreto seguro y usa un relé privado si es posible. Clave pública de conexión a billetera diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c1b1eed46..67e22f4e4 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -61,7 +61,7 @@ Az aláíró nem engedélyezte a művelet végrehajtásához szükséges visszafejtést. Aktiválja az NIP-44 visszafejtést az aláíró alkalmazásban, és próbálja meg újra Nem található az aláíró Az aláíró alkalmazás el lett távolítva? Ellenőrizze, hogy az aláíró telepítve van-e és rendelkezik-e ezzel a fiókkal. Jelentkezzen ki és jelentkezzen be újra, ha az aláíró alkalmazás megváltozott. - Zap + Zap-ek Megtekintések száma Megtolás megtolta @@ -1034,6 +1034,12 @@ Nem sikerült előkészíteni a fejlécadatokat: %1$s Tömörítés visszavonva A tömörítés nem tudott visszaadni egy fájlt + Fájlok titkosítása + Az adatok védelme érdekében titkosítsa a fájlokat a feltöltés előtt. Egyes kiszolgálók az ingyenes fiókok esetében nem biztos, hogy elfogadják a titkosított fájlokat. + Titkosított feltöltés sikertelen + Sok kiszolgáló nem fogad el titkosított fájlokat ingyenes fiókok esetén. Próbálja újra titkosítás nélkül. + Újrapróbálás titkosítás nélkül + Figyelem: Titkosítás nélkül bárki láthatja a tartalmat, akinek megvan a fájlhoz tartozó hivatkozás. Média minősége Válassza az alacsony minőséget a média kisebb, de kevésbé jó minőségű fájlba tömörítéséhez, a magas minőséget a nagyobb, de jobb minőségű fájlba tömörítéshez, vagy a tömörítetlen fájlt a tömörítés nélküli feltöltéshez. Alacsony @@ -1076,6 +1082,9 @@ Fogadott Elküldött Frissítés + Összes + Zap-ek + Nem-zap-ek Biztonsági szűrők Követettek importálása Új bejegyzés @@ -1563,4 +1572,5 @@ Közvetlen üzenetek profilok átjátszóbeállítások + Utoljára %1$s ezelőtt látták diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 3e737905b..b63b16724 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1031,6 +1031,12 @@ Nie można przygotować informacji nagłówkowych: %1$s Kompresja anulowana Kompresja nie powiodła się przy zwracaniu pliku + Szyfrowanie plików + W celu zapewnienia prywatności zaszyfruj pliki przed ich przesłaniem. Niektóre serwery mogą nie akceptować zaszyfrowanych plików na bezpłatnych kontach. + Nie udało się przesłać zaszyfrowanego pliku + Wiele serwerów nie akceptuje plików zaszyfrowanych na kontach bezpłatnych. Możesz spróbować ponownie bez szyfrowania. + Ponów bez szyfrowania + Ostrzeżenie: Bez szyfrowania każdy, kto posiada link do pliku, może zobaczyć jego zawartość. Jakość Załącznika Wybierz Niską jakość, aby skompresować media do mniejszego pliku o mniejszej jakości lub wybierz Wysoką jakość, aby skompresować do większego pliku o wyższej jakości. Niska @@ -1073,6 +1079,8 @@ Otrzymano Wysłano Odśwież + Zapy + Bez zapów Filtry bezpieczeństwa Importuj Obserwujących Nowy post @@ -1455,6 +1463,7 @@ Najważniejsze informacje Indeks listy transmiterów Opisane zakładki + Na żywo Zapy Zapytanie NWC Odpowiedź NWC @@ -1523,4 +1532,8 @@ Obserwujesz %1$d kont(a)… "Wprowadź profil znajomego lub lidera społeczności. Możesz użyć ich npub-u, adresu NIP-05 lub nazwy jak alicja@domena.pl lub id/alicja dla zweryfikowanych przez blockchain tożsamości." Wybierz Wszystkie + wydarzenia + profile + ustawienia transmiterów + Ostatnio widziano %1$s temu diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index ab708033f..d41216733 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -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.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent @@ -108,6 +109,9 @@ interface IAccount { /** Send a NIP-17 gift-wrapped direct message */ suspend fun sendNip17PrivateMessage(template: EventTemplate) + /** Send a NIP-17 gift-wrapped encrypted file header */ + suspend fun sendNip17EncryptedFile(template: EventTemplate) + /** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */ suspend fun sendGiftWraps(wraps: List) } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 1a728e171..f17e8090e 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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")) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 86b7e9d13..f06acf2e7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -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 + 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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 30470359f..3d106a9e4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 87e9aa900..5e3108b2e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -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.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent @@ -156,6 +157,37 @@ class DesktopIAccount( scope.launch { dmSendTracker.sendBatch(batch) } } + override suspend fun sendNip17EncryptedFile(template: EventTemplate) { + 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) { val batch = wraps.map { wrap -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt new file mode 100644 index 000000000..6e534d068 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt @@ -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 { + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? = + if (data.scheme == "data") { + DesktopBase64Fetcher(data) + } else { + null + } + } + + object BKeyer : Keyer { + 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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt new file mode 100644 index 000000000..1e4728899 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt @@ -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 { + override fun create( + data: BlurhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = DesktopBlurHashFetcher(data) + } + + object BKeyer : Keyer { + 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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt new file mode 100644 index 000000000..ebaad3df6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -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", + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt new file mode 100644 index 000000000..185b0731a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/SkiaGifDecoder.kt @@ -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) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt new file mode 100644 index 000000000..63765f0dd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt @@ -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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt new file mode 100644 index 000000000..4365bd881 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt @@ -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() + 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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt new file mode 100644 index 000000000..dc59b159c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -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(null) + val videoFrame: StateFlow = _videoFrame.asStateFlow() + + private val _videoState = MutableStateFlow(MediaPlaybackState()) + val videoState: StateFlow = _videoState.asStateFlow() + + // Audio state + private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) + val audioState: StateFlow = _audioState.asStateFlow() + + // Fullscreen + private val _isFullscreen = MutableStateFlow(false) + val isFullscreen: StateFlow = _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) {} + } + + 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) { + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt new file mode 100644 index 000000000..8e92dc92e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -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 { + 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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt new file mode 100644 index 000000000..bfa1b3295 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt new file mode 100644 index 000000000..6ecaabe15 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -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() + private val pending = ConcurrentHashMap() + + 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) {} + } + + 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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt new file mode 100644 index 000000000..18e08c306 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -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() + private val idlePlayers = ConcurrentLinkedQueue() + private const val MAX_POOL_SIZE = 1 + + // Thumbnail player pool (separate so thumbnails don't compete with playback) + private val allThumbPlayers = mutableListOf() + private val idleThumbPlayers = ConcurrentLinkedQueue() + 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() + private val idleAudioPlayers = ConcurrentLinkedQueue() + 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) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt new file mode 100644 index 000000000..b93214497 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt @@ -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" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt new file mode 100644 index 000000000..43842b38a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt @@ -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(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(it.body.string()) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt new file mode 100644 index 000000000..c197441d7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt @@ -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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt new file mode 100644 index 000000000..d43b3cfd3 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt @@ -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" + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt new file mode 100644 index 000000000..e22f00abc --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt @@ -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, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt new file mode 100644 index 000000000..e79244ecf --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt @@ -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() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index c4f1570c6..f08682e56 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -289,7 +289,9 @@ fun BookmarksScreen( ) { NoteCard( note = event.toNoteDisplayData(localCache), + localCache = localCache, onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, ) NoteActionsRow( event = event, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index dcf903fa7..0c955558e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -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(null) } val scope = rememberCoroutineScope() + val attachedFiles = remember { mutableStateListOf() } + 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 + 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() + 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): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + val props = mutableMapOf>() + 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): List = + 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, 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 = 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) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e539b1510..b8fee1344 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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, + 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, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, zapReceipts: List = 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(null) } + var lightboxState by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(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() + .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() - .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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index da405b6c4..c866b0ba5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -449,6 +449,7 @@ fun SearchScreen( state = state, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, + localCache = localCache, ) } else if (!debouncedQuery.isEmpty && !isSearching) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ca79ca553..d4368f984 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -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(null) } var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } - // Load metadata for thread authors via coordinator + // Lightbox state + var lightboxState by remember { mutableStateOf(null) } + + // Load metadata for thread authors + mentioned users via coordinator LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { if (subscriptionsCoordinator != null) { val pubkeys = mutableListOf() 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() - .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() + .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() - .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() + .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 } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 75769e59f..15f0a0de6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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(null) } var retryTrigger by remember { mutableStateOf(0) } + // Tab and gallery state + var selectedTab by remember { mutableStateOf(0) } + var lightboxState by remember { mutableStateOf(null) } + val pictureEvents = remember { mutableStateListOf() } + // 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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt new file mode 100644 index 000000000..8c9993d67 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -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(null) } + var decryptedBytes by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(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" + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 6c936d885..a5e360c1b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -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() } + var isUploading by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + // Helper: attach files and auto-force NIP-17 if needed + fun attachFiles(files: List) { + 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 + 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, + 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) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index ec2c9d9e7..8f6bb36dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -119,7 +119,6 @@ fun ConversationListPane( Column( modifier = modifier - .width(280.dp) .fillMaxHeight() .focusRequester(focusRequester) .focusable() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 599ee6929..fa8da9e2e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -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() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index b59abab0f..4e3ff3016 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 1df6bf135..519d93217 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index a00dc029a..ea458daa2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt new file mode 100644 index 000000000..1872b7b04 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -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, + val durations: List, +) + +@Composable +fun AnimatedGifImage( + url: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) { + var gifFrames by remember(url) { mutableStateOf(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(decodableFrames) + val durations = ArrayList(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 + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt new file mode 100644 index 000000000..c02f4167b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt @@ -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, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt new file mode 100644 index 000000000..2d398d54e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ClipboardPasteHandler.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +object ClipboardPasteHandler { + fun getClipboardFiles(): List { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + return try { + when { + clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor) -> { + @Suppress("UNCHECKED_CAST") + (clipboard.getData(DataFlavor.javaFileListFlavor) as? List) ?: emptyList() + } + + clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor) -> { + val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return emptyList() + val buffered = toBufferedImage(image) + val tempFile = File.createTempFile("clipboard_", ".png") + tempFile.deleteOnExit() + ImageIO.write(buffered, "png", tempFile) + listOf(tempFile) + } + + else -> { + emptyList() + } + } + } catch (_: Exception) { + emptyList() + } + } + + private fun toBufferedImage(image: Image): BufferedImage { + if (image is BufferedImage) return image + val buffered = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB) + buffered.graphics.drawImage(image, 0, 0, null) + return buffered + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt new file mode 100644 index 000000000..428a9983a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopFilePicker.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.io.FilenameFilter + +object DesktopFilePicker { + private val mediaExtensions = + setOf( + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "avif", + "mp4", + "webm", + "mov", + "mp3", + "ogg", + "wav", + "flac", + ) + + fun pickMediaFiles(parent: Frame? = null): List { + val dialog = + FileDialog(parent, "Select Media", FileDialog.LOAD).apply { + isMultipleMode = true + filenameFilter = + FilenameFilter { _, name -> + val ext = name.substringAfterLast('.', "").lowercase() + ext in mediaExtensions + } + } + dialog.isVisible = true + return dialog.files?.toList() ?: emptyList() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt new file mode 100644 index 000000000..ae3b082bc --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -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(), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt new file mode 100644 index 000000000..8cd466742 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/FullscreenHelper.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt new file mode 100644 index 000000000..18a59732f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -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() + } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt new file mode 100644 index 000000000..194bad9f7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -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 { null } + +val LocalAwtWindow = compositionLocalOf { 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, + 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.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() + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt new file mode 100644 index 000000000..a8713d047 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/MediaAttachmentRow.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import java.io.File + +@Composable +fun MediaAttachmentRow( + attachedFiles: List, + isUploading: Boolean, + onAttach: () -> Unit, + onPaste: () -> Unit, + onRemove: (File) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAttach) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach media", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onPaste) { + Icon( + Icons.Default.ContentPaste, + contentDescription = "Paste from clipboard", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isUploading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) + Spacer(Modifier.height(4.dp)) + } + + if (attachedFiles.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (file in attachedFiles) { + AttachedFileThumbnail(file = file, onRemove = { onRemove(file) }) + } + } + } + } +} + +@Composable +private fun AttachedFileThumbnail( + file: File, + onRemove: () -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row { + AsyncImage( + model = file, + contentDescription = file.name, + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + IconButton(onClick = onRemove, modifier = Modifier.size(20.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(Modifier.width(4.dp)) + Text( + text = file.name.take(12), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt new file mode 100644 index 000000000..978ab591a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -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), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt new file mode 100644 index 000000000..ba2159ccc --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -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, 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, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt new file mode 100644 index 000000000..e29518db6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -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 + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt new file mode 100644 index 000000000..1dbd20837 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -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) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt new file mode 100644 index 000000000..9813b0807 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -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, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index aca0f12d9..44417e19a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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, Int) -> Unit)? = null, + onMediaClick: ((List, 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): List = + 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() + 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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt new file mode 100644 index 000000000..f624e286d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/GalleryTab.kt @@ -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, + onImageClick: ((List, 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, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index 2a8a36085..2a50d2ce8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -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 ExpandableSection( remaining: List, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt new file mode 100644 index 000000000..31f7031e4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -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 = emptyList(), + onServersChanged: (List) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val servers = remember { mutableStateListOf().apply { addAll(initialServers) } } + val serverStatuses = remember { mutableStateMapOf() } + 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, + ) + } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt new file mode 100644 index 000000000..8921264ec --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt @@ -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) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt new file mode 100644 index 000000000..bd0a6a205 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt @@ -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) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt new file mode 100644 index 000000000..75b94e6a8 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt @@ -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() + val mockCall = mockk() + val mockClient = mockk() + + 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 { + 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 { + 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() + val mockCall = mockk() + val mockClient = mockk() + + 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() + val mockCall = mockk() + val mockClient = mockk() + + 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() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt new file mode 100644 index 000000000..6cef50f6f --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt new file mode 100644 index 000000000..2bb0f4016 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt new file mode 100644 index 000000000..b2ba3f3ba --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt @@ -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() + val fileSlot = slot() + val contentTypeSlot = slot() + val urlSlot = slot() + + 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(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() + val fileSlot = slot() + + 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(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() + + coEvery { + mockClient.upload(any(), any(), any(), 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(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() + val authSlot = slot() + + 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(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() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt new file mode 100644 index 000000000..085f96046 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt @@ -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) + } +} diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 000000000..979b4c999 --- /dev/null +++ b/docs/TODO.md @@ -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. diff --git a/docs/brainstorms/2026-03-16-blossom-protocol-research.md b/docs/brainstorms/2026-03-16-blossom-protocol-research.md new file mode 100644 index 000000000..103034268 --- /dev/null +++ b/docs/brainstorms/2026-03-16-blossom-protocol-research.md @@ -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 / -- 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 + + +``` + +- 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 / -- 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 /` 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 +Content-Type: image/png +Content-Length: 184292 +X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553 + + +``` + +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/ -- 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 / -- Delete Blob + +```http +DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +``` + +- 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 +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 +Content-Type: image/png +Content-Length: 4194304 + + +``` + +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 +``` + +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: "" +X-Lightning: "" +``` + +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", "", "illegal"], + ["p", ""] + ] +} +``` + +Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal. + +--- + +## BUD-10: Blossom URI Scheme + +Format: +``` +blossom:.[?param1=value1¶m2=value2...] +``` + +Example: +``` +blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=&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 /` +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": "", + "pubkey": "", + "created_at": 1725105921, + "kind": 24242, + "tags": [ + ["t", "upload"], + ["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"], + ["expiration", "1725109521"], + ["server", "cdn.example.com"] + ], + "content": "Upload cat photo", + "sig": "" +} +``` + +### 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 +``` + +Note: Amethyst uses standard Base64 (not Base64url), which works in practice. + +### Endpoint Authorization Requirements + +| Endpoint | `t` tag | Hash source | `x` tag | +|----------|---------|-------------|---------| +| GET/HEAD / | `get` | URL path | optional | +| PUT /upload | `upload` | X-SHA-256 header | required | +| HEAD /upload | `upload` | X-SHA-256 header | required | +| DELETE / | `delete` | URL path | required | +| GET /list/ | `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 `, `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. diff --git a/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md new file mode 100644 index 000000000..7793b1090 --- /dev/null +++ b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md @@ -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:.` + +### 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? + 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", ""], // scope to specific blob + ["expiration", ""], // required + ["server", "cdn.example.com"] // scope to server + ] +) +// Sent as: Authorization: Nostr +``` + +## 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? diff --git a/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md new file mode 100644 index 000000000..543857c56 --- /dev/null +++ b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md @@ -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) | diff --git a/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md new file mode 100644 index 000000000..640609897 --- /dev/null +++ b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md @@ -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. diff --git a/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md new file mode 100644 index 000000000..e106e509d --- /dev/null +++ b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md @@ -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. diff --git a/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md new file mode 100644 index 000000000..074e1b112 --- /dev/null +++ b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md @@ -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? diff --git a/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md new file mode 100644 index 000000000..e7e02c445 --- /dev/null +++ b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md @@ -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) { + byteArray = ByteArray(buffers[0].limit()) + } + }, + object : RenderCallback { + override fun display(mp: MediaPlayer, bufs: Array, 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 + 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 ` 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=`, `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=`, `expiration=+1hr`, `server=` +3. Base64-encode (standard base64 works despite spec saying base64url) +4. `PUT /upload` with `Authorization: Nostr `, `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` (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? diff --git a/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md new file mode 100644 index 000000000..94c4eeebb --- /dev/null +++ b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md @@ -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(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, // NEW + onMessageChange: (String) -> Unit, + onToggleNip17: () -> Unit, + onAttachFiles: (List) -> 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, + 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) -> 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()` 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) +``` + +```kotlin +// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly) +override suspend fun sendNip17EncryptedFile( + template: EventTemplate, +) { + 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>, + 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>() + + 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.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.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(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) diff --git a/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md new file mode 100644 index 000000000..bdb90980e --- /dev/null +++ b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6a717466..9e11c3afd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }