mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #1873 from nrobi144/feat/desktop-media
feat(desktop): Full media parity — images, video, audio, encrypted DMs, upload, lightbox
This commit is contained in:
@@ -153,3 +153,8 @@ TASKS.md
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# Downloaded VLC binaries (vlc-setup plugin)
|
||||
desktopApp/src/jvmMain/appResources/linux/
|
||||
desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
@@ -1609,7 +1609,7 @@ class Account(
|
||||
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
|
||||
}
|
||||
|
||||
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
@@ -108,6 +109,9 @@ interface IAccount {
|
||||
/** Send a NIP-17 gift-wrapped direct message */
|
||||
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>)
|
||||
|
||||
/** Send a NIP-17 gift-wrapped encrypted file header */
|
||||
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
|
||||
|
||||
/** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */
|
||||
suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
+13
@@ -68,4 +68,17 @@ object DesktopPreferences {
|
||||
set(value) {
|
||||
prefs.put(KEY_LAYOUT_MODE, value)
|
||||
}
|
||||
|
||||
private const val KEY_BLOSSOM_SERVERS = "blossom_servers"
|
||||
private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net"
|
||||
|
||||
var blossomServers: List<String>
|
||||
get() {
|
||||
val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER)
|
||||
return if (raw.isBlank()) emptyList() else raw.split(",")
|
||||
}
|
||||
set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(","))
|
||||
|
||||
val preferredBlossomServer: String
|
||||
get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
@@ -48,6 +50,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -74,6 +77,8 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
|
||||
@@ -87,8 +92,12 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -137,7 +146,17 @@ sealed class DesktopScreen {
|
||||
data object Settings : DesktopScreen()
|
||||
}
|
||||
|
||||
fun main() =
|
||||
fun main() {
|
||||
DesktopImageLoaderSetup.setup()
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread {
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.shutdown()
|
||||
VlcjPlayerPool.shutdown()
|
||||
},
|
||||
)
|
||||
// Pre-init VLC on background thread so first play is fast
|
||||
Thread { VlcjPlayerPool.init() }.start()
|
||||
application {
|
||||
val windowState =
|
||||
rememberWindowState(
|
||||
@@ -363,27 +382,35 @@ fun main() =
|
||||
}
|
||||
}
|
||||
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
onShowReplyDialog = { event ->
|
||||
replyToNote = event
|
||||
showComposeDialog = true
|
||||
},
|
||||
onDismissComposeDialog = {
|
||||
showComposeDialog = false
|
||||
replyToNote = null
|
||||
},
|
||||
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
||||
onShowAddColumnDialog = { showAddColumnDialog = true },
|
||||
replyToNote = replyToNote,
|
||||
)
|
||||
val immersiveFullscreenState = remember { mutableStateOf(false) }
|
||||
CompositionLocalProvider(
|
||||
LocalWindowState provides windowState,
|
||||
LocalAwtWindow provides window,
|
||||
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
|
||||
) {
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
onShowReplyDialog = { event ->
|
||||
replyToNote = event
|
||||
showComposeDialog = true
|
||||
},
|
||||
onDismissComposeDialog = {
|
||||
showComposeDialog = false
|
||||
replyToNote = null
|
||||
},
|
||||
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
||||
onShowAddColumnDialog = { showAddColumnDialog = true },
|
||||
replyToNote = replyToNote,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
@@ -622,22 +649,31 @@ fun MainContent(
|
||||
)
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -> {
|
||||
val innerNote = localCache.getOrCreateNote(innerEvent.id)
|
||||
val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey)
|
||||
if (innerNote.event == null) {
|
||||
innerNote.loadEvent(innerEvent, innerAuthor, emptyList())
|
||||
}
|
||||
iAccount.chatroomList.addMessage(
|
||||
innerEvent.chatroomKey(iAccount.pubKey),
|
||||
innerNote,
|
||||
)
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> {
|
||||
val reactionNote = localCache.getOrCreateNote(innerEvent.id)
|
||||
val reactionAuthor = localCache.getOrCreateUser(innerEvent.pubKey)
|
||||
if (reactionNote.event == null) {
|
||||
reactionNote.loadEvent(innerEvent, reactionAuthor, emptyList())
|
||||
}
|
||||
// Attach reaction to the target message note
|
||||
innerEvent.originalPost().forEach { targetId ->
|
||||
val targetNote = localCache.getNoteIfExists(targetId)
|
||||
targetNote?.addReaction(reactionNote)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
println("Unhandled NIP-17 inner event: ${innerEvent.kind}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,60 +701,76 @@ fun MainContent(
|
||||
}
|
||||
}
|
||||
|
||||
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAddColumnDialog,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end Row
|
||||
|
||||
LayoutMode.DECK -> {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAddColumnDialog,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
// Persistent media control bar
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.NowPlayingBar()
|
||||
} // end Column
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global fullscreen video overlay
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.GlobalFullscreenOverlay()
|
||||
|
||||
// Snackbar for zap feedback
|
||||
SnackbarHost(
|
||||
@@ -781,7 +833,9 @@ fun RelaySettingsScreen(
|
||||
accountManager.loadNwcConnection()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -872,6 +926,15 @@ fun RelaySettingsScreen(
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Media Server Settings
|
||||
MediaServerSettings(
|
||||
initialServers = DesktopPreferences.blossomServers,
|
||||
onServersChanged = { DesktopPreferences.blossomServers = it },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Developer Settings Section (only in debug mode)
|
||||
if (DebugConfig.isDebugMode) {
|
||||
com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
+32
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
@@ -156,6 +157,37 @@ class DesktopIAccount(
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
|
||||
// Optimistic local add
|
||||
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
|
||||
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
|
||||
|
||||
// Collect wraps with target relays and send
|
||||
val batch =
|
||||
result.wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays =
|
||||
if (recipientKey != null) {
|
||||
val dmRelays =
|
||||
localCache
|
||||
.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelays()
|
||||
?.toSet()
|
||||
dmRelays?.ifEmpty { null }
|
||||
?: relayManager.connectedRelays.value
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
|
||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||
val batch =
|
||||
wraps.map { wrap ->
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.images
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil3.ImageLoader
|
||||
import coil3.Uri
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Base64Image
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
@Stable
|
||||
class DesktopBase64Fetcher(
|
||||
private val data: Uri,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? =
|
||||
runCatching {
|
||||
val platformImage = Base64Image.toPlatformImage(data.toString())
|
||||
val bufferedImage = platformImage.toBufferedImage()
|
||||
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
|
||||
ImageFetchResult(
|
||||
image = bitmap.asImage(true),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? =
|
||||
if (data.scheme == "data") {
|
||||
DesktopBase64Fetcher(data)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
object BKeyer : Keyer<Uri> {
|
||||
override fun key(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
): String? =
|
||||
if (data.scheme == "data") {
|
||||
sha256(data.toString().toByteArray()).toHexKey()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bufferedImageToSkiaBitmap(bi: BufferedImage): Bitmap {
|
||||
val w = bi.width
|
||||
val h = bi.height
|
||||
val pixels = IntArray(w * h)
|
||||
bi.getRGB(0, 0, w, h, pixels, 0, w)
|
||||
val bitmap = Bitmap()
|
||||
bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
|
||||
bitmap.installPixels(convertArgbToBgra(pixels))
|
||||
bitmap.setImmutable()
|
||||
return bitmap
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.images
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
|
||||
|
||||
data class BlurhashWrapper(
|
||||
val blurhash: String,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class DesktopBlurHashFetcher(
|
||||
private val data: BlurhashWrapper,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val hash = data.blurhash
|
||||
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
|
||||
val bufferedImage = platformImage.toBufferedImage()
|
||||
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
|
||||
|
||||
return ImageFetchResult(
|
||||
image = bitmap.asImage(true),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<BlurhashWrapper> {
|
||||
override fun create(
|
||||
data: BlurhashWrapper,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher = DesktopBlurHashFetcher(data)
|
||||
}
|
||||
|
||||
object BKeyer : Keyer<BlurhashWrapper> {
|
||||
override fun key(
|
||||
data: BlurhashWrapper,
|
||||
options: Options,
|
||||
): String = data.blurhash
|
||||
}
|
||||
}
|
||||
|
||||
internal fun convertArgbToBgra(pixels: IntArray): ByteArray {
|
||||
val bytes = ByteArray(pixels.size * 4)
|
||||
for (i in pixels.indices) {
|
||||
val argb = pixels[i]
|
||||
val a = (argb shr 24) and 0xFF
|
||||
val r = (argb shr 16) and 0xFF
|
||||
val g = (argb shr 8) and 0xFF
|
||||
val b = argb and 0xFF
|
||||
val offset = i * 4
|
||||
bytes[offset] = b.toByte()
|
||||
bytes[offset + 1] = g.toByte()
|
||||
bytes[offset + 2] = r.toByte()
|
||||
bytes[offset + 3] = a.toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
+94
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Handles decryption of media files for NIP-17 DMs.
|
||||
* Uses AESGCM cipher from quartz (commonMain).
|
||||
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
|
||||
*/
|
||||
object EncryptedMediaService {
|
||||
private val httpClient = OkHttpClient()
|
||||
private val cache = ConcurrentHashMap<String, ByteArray>()
|
||||
private const val MAX_CACHE_ENTRIES = 20
|
||||
|
||||
/**
|
||||
* Download and decrypt an encrypted file from a URL.
|
||||
* Results are cached by URL to avoid re-downloading on scroll/recomposition.
|
||||
* Returns the decrypted bytes.
|
||||
*/
|
||||
suspend fun downloadAndDecrypt(
|
||||
url: String,
|
||||
keyBytes: ByteArray,
|
||||
nonce: ByteArray,
|
||||
): ByteArray {
|
||||
cache[url]?.let { return it }
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val encryptedBytes =
|
||||
response.use {
|
||||
if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}")
|
||||
it.body.bytes()
|
||||
}
|
||||
|
||||
val cipher = AESGCM(keyBytes, nonce)
|
||||
val decrypted = cipher.decrypt(encryptedBytes)
|
||||
|
||||
// Evict oldest entries if cache is full
|
||||
if (cache.size >= MAX_CACHE_ENTRIES) {
|
||||
cache.keys.firstOrNull()?.let { cache.remove(it) }
|
||||
}
|
||||
cache[url] = decrypted
|
||||
|
||||
decrypted
|
||||
}
|
||||
}
|
||||
}
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
||||
import java.nio.ByteBuffer
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
data class MediaPlaybackState(
|
||||
val url: String? = null,
|
||||
val type: MediaType = MediaType.VIDEO,
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val position: Float = 0f,
|
||||
val duration: Long = 0L,
|
||||
val currentTime: Long = 0L,
|
||||
val aspectRatio: Float = 16f / 9f,
|
||||
val volume: Int = 100,
|
||||
val isMuted: Boolean = false,
|
||||
)
|
||||
|
||||
object GlobalMediaPlayer {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
// Video state
|
||||
private val _videoFrame = MutableStateFlow<ImageBitmap?>(null)
|
||||
val videoFrame: StateFlow<ImageBitmap?> = _videoFrame.asStateFlow()
|
||||
|
||||
private val _videoState = MutableStateFlow(MediaPlaybackState())
|
||||
val videoState: StateFlow<MediaPlaybackState> = _videoState.asStateFlow()
|
||||
|
||||
// Audio state
|
||||
private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO))
|
||||
val audioState: StateFlow<MediaPlaybackState> = _audioState.asStateFlow()
|
||||
|
||||
// Fullscreen
|
||||
private val _isFullscreen = MutableStateFlow(false)
|
||||
val isFullscreen: StateFlow<Boolean> = _isFullscreen.asStateFlow()
|
||||
|
||||
// VLC players — kept alive between plays
|
||||
private var videoPlayer: EmbeddedMediaPlayer? = null
|
||||
private var audioPlayer: MediaPlayer? = null
|
||||
|
||||
// Skia bitmap for video rendering
|
||||
private var skBitmap: Bitmap? = null
|
||||
private var pixelBytes: ByteArray? = null
|
||||
|
||||
// Position polling job
|
||||
private var videoPollingJob: Job? = null
|
||||
private var audioPollingJob: Job? = null
|
||||
|
||||
fun playVideo(
|
||||
url: String,
|
||||
seekPosition: Float = 0f,
|
||||
) {
|
||||
// If already playing this URL, just seek
|
||||
val current = _videoState.value
|
||||
if (current.url == url && videoPlayer != null) {
|
||||
if (seekPosition > 0f) {
|
||||
videoPlayer?.controls()?.setPosition(seekPosition)
|
||||
}
|
||||
if (!current.isPlaying) {
|
||||
videoPlayer?.controls()?.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Stop current video if different URL
|
||||
if (current.url != null && current.url != url) {
|
||||
videoPlayer?.controls()?.stop()
|
||||
}
|
||||
|
||||
_videoState.value =
|
||||
MediaPlaybackState(
|
||||
url = url,
|
||||
type = MediaType.VIDEO,
|
||||
isBuffering = true,
|
||||
)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (!VlcjPlayerPool.init()) {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val player =
|
||||
videoPlayer ?: VlcjPlayerPool.acquire() ?: run {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Only set up surface on first acquisition
|
||||
if (videoPlayer == null) {
|
||||
setupVideoSurface(player)
|
||||
setupVideoEventListener(player)
|
||||
videoPlayer = player
|
||||
}
|
||||
|
||||
var didSeek = seekPosition <= 0f
|
||||
|
||||
// Temporary listener for initial seek
|
||||
if (!didSeek) {
|
||||
val seekListener =
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
if (!didSeek) {
|
||||
didSeek = true
|
||||
mediaPlayer.controls().setPosition(seekPosition)
|
||||
mediaPlayer.events().removeMediaPlayerEventListener(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
player.events().addMediaPlayerEventListener(seekListener)
|
||||
}
|
||||
|
||||
val vol = _videoState.value.volume
|
||||
player.media().play(url, ":start-volume=$vol")
|
||||
startVideoPolling()
|
||||
}
|
||||
}
|
||||
|
||||
fun playAudio(url: String) {
|
||||
val current = _audioState.value
|
||||
if (current.url == url && audioPlayer != null) {
|
||||
if (!current.isPlaying) {
|
||||
audioPlayer?.controls()?.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (current.url != null && current.url != url) {
|
||||
audioPlayer?.controls()?.stop()
|
||||
}
|
||||
|
||||
_audioState.value =
|
||||
MediaPlaybackState(
|
||||
url = url,
|
||||
type = MediaType.AUDIO,
|
||||
isBuffering = true,
|
||||
)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val player =
|
||||
audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run {
|
||||
_audioState.value = _audioState.value.copy(isBuffering = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (audioPlayer == null) {
|
||||
setupAudioEventListener(player)
|
||||
audioPlayer = player
|
||||
}
|
||||
|
||||
val vol = _audioState.value.volume
|
||||
player.media().play(url, ":start-volume=$vol")
|
||||
startAudioPolling()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleVideoPlayPause() {
|
||||
val player = videoPlayer ?: return
|
||||
val state = _videoState.value
|
||||
if (state.url == null) return
|
||||
|
||||
if (state.isPlaying) {
|
||||
player.controls().pause()
|
||||
} else {
|
||||
if (state.position <= 0f && !player.status().isPlaying) {
|
||||
state.url.let { player.media().play(it) }
|
||||
} else {
|
||||
player.controls().play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAudioPlayPause() {
|
||||
val player = audioPlayer ?: return
|
||||
val state = _audioState.value
|
||||
if (state.url == null) return
|
||||
|
||||
if (state.isPlaying) {
|
||||
player.controls().pause()
|
||||
} else {
|
||||
if (state.position <= 0f && !player.status().isPlaying) {
|
||||
state.url.let { player.media().play(it) }
|
||||
} else {
|
||||
player.controls().play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun seekVideo(position: Float) {
|
||||
videoPlayer?.controls()?.setPosition(position)
|
||||
}
|
||||
|
||||
fun seekAudio(position: Float) {
|
||||
audioPlayer?.controls()?.setPosition(position)
|
||||
}
|
||||
|
||||
fun setVideoVolume(volume: Int) {
|
||||
videoPlayer?.audio()?.setVolume(volume)
|
||||
_videoState.value = _videoState.value.copy(volume = volume)
|
||||
}
|
||||
|
||||
fun setAudioVolume(volume: Int) {
|
||||
audioPlayer?.audio()?.setVolume(volume)
|
||||
_audioState.value = _audioState.value.copy(volume = volume)
|
||||
}
|
||||
|
||||
fun toggleVideoMute() {
|
||||
val muted = !_videoState.value.isMuted
|
||||
videoPlayer?.audio()?.isMute = muted
|
||||
_videoState.value = _videoState.value.copy(isMuted = muted)
|
||||
}
|
||||
|
||||
fun toggleAudioMute() {
|
||||
val muted = !_audioState.value.isMuted
|
||||
audioPlayer?.audio()?.isMute = muted
|
||||
_audioState.value = _audioState.value.copy(isMuted = muted)
|
||||
}
|
||||
|
||||
fun stopVideo() {
|
||||
videoPollingJob?.cancel()
|
||||
videoPollingJob = null
|
||||
videoPlayer?.controls()?.stop()
|
||||
_videoState.value = MediaPlaybackState()
|
||||
_videoFrame.value = null
|
||||
_isFullscreen.value = false
|
||||
}
|
||||
|
||||
fun stopAudio() {
|
||||
audioPollingJob?.cancel()
|
||||
audioPollingJob = null
|
||||
audioPlayer?.controls()?.stop()
|
||||
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
|
||||
}
|
||||
|
||||
fun toggleFullscreen() {
|
||||
_isFullscreen.value = !_isFullscreen.value
|
||||
}
|
||||
|
||||
fun exitFullscreen() {
|
||||
_isFullscreen.value = false
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
videoPollingJob?.cancel()
|
||||
audioPollingJob?.cancel()
|
||||
|
||||
videoPlayer?.let { p ->
|
||||
try {
|
||||
p.controls().stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
VlcjPlayerPool.release(p)
|
||||
}
|
||||
videoPlayer = null
|
||||
|
||||
audioPlayer?.let { p ->
|
||||
try {
|
||||
p.controls().stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
VlcjPlayerPool.releaseAudioPlayer(p)
|
||||
}
|
||||
audioPlayer = null
|
||||
|
||||
_videoState.value = MediaPlaybackState()
|
||||
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
|
||||
_videoFrame.value = null
|
||||
_isFullscreen.value = false
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun setupVideoSurface(player: EmbeddedMediaPlayer) {
|
||||
val bufferFormatCallback =
|
||||
object : BufferFormatCallback {
|
||||
override fun getBufferFormat(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
): BufferFormat {
|
||||
if (sourceHeight > 0) {
|
||||
_videoState.value =
|
||||
_videoState.value.copy(
|
||||
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(),
|
||||
)
|
||||
}
|
||||
val bmp = Bitmap()
|
||||
bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
|
||||
skBitmap = bmp
|
||||
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
|
||||
return RV32BufferFormat(sourceWidth, sourceHeight)
|
||||
}
|
||||
|
||||
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
|
||||
}
|
||||
|
||||
val renderCallback =
|
||||
RenderCallback { _, nativeBuffers, _ ->
|
||||
val bmp = skBitmap ?: return@RenderCallback
|
||||
val bytes = pixelBytes ?: return@RenderCallback
|
||||
val buffer = nativeBuffers[0]
|
||||
buffer.rewind()
|
||||
buffer.get(bytes)
|
||||
bmp.installPixels(bytes)
|
||||
_videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
||||
}
|
||||
|
||||
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
|
||||
player.videoSurface().set(surface)
|
||||
}
|
||||
|
||||
private fun setupVideoEventListener(player: EmbeddedMediaPlayer) {
|
||||
player.events().addMediaPlayerEventListener(
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
val state = _videoState.value
|
||||
_videoState.value =
|
||||
state.copy(
|
||||
isPlaying = true,
|
||||
isBuffering = false,
|
||||
duration = mediaPlayer.status().length(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun paused(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value = _videoState.value.copy(isPlaying = false)
|
||||
}
|
||||
|
||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false)
|
||||
}
|
||||
|
||||
override fun buffering(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newCache: Float,
|
||||
) {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = newCache < 100f)
|
||||
}
|
||||
|
||||
override fun positionChanged(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newPosition: Float,
|
||||
) {
|
||||
_videoState.value =
|
||||
_videoState.value.copy(
|
||||
position = newPosition,
|
||||
currentTime = (newPosition * _videoState.value.duration).toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun finished(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value =
|
||||
_videoState.value.copy(
|
||||
isPlaying = false,
|
||||
isBuffering = false,
|
||||
position = 0f,
|
||||
currentTime = 0L,
|
||||
)
|
||||
}
|
||||
|
||||
override fun error(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = false)
|
||||
println("VLC: playback error for ${_videoState.value.url}")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupAudioEventListener(player: MediaPlayer) {
|
||||
player.events().addMediaPlayerEventListener(
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value =
|
||||
_audioState.value.copy(
|
||||
isPlaying = true,
|
||||
isBuffering = false,
|
||||
duration = mediaPlayer.status().length(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun paused(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value = _audioState.value.copy(isPlaying = false)
|
||||
}
|
||||
|
||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false)
|
||||
}
|
||||
|
||||
override fun positionChanged(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newPosition: Float,
|
||||
) {
|
||||
_audioState.value =
|
||||
_audioState.value.copy(
|
||||
position = newPosition,
|
||||
currentTime = (newPosition * _audioState.value.duration).toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun finished(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value =
|
||||
_audioState.value.copy(
|
||||
isPlaying = false,
|
||||
position = 0f,
|
||||
currentTime = 0L,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun startVideoPolling() {
|
||||
videoPollingJob?.cancel()
|
||||
videoPollingJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(500)
|
||||
val player = videoPlayer ?: break
|
||||
val state = _videoState.value
|
||||
if (state.isPlaying) {
|
||||
try {
|
||||
_videoState.value =
|
||||
state.copy(
|
||||
position = player.status().position(),
|
||||
currentTime = player.status().time(),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAudioPolling() {
|
||||
audioPollingJob?.cancel()
|
||||
audioPollingJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(500)
|
||||
val player = audioPlayer ?: break
|
||||
val state = _audioState.value
|
||||
if (state.isPlaying) {
|
||||
try {
|
||||
_audioState.value =
|
||||
state.copy(
|
||||
position = player.status().position(),
|
||||
currentTime = player.status().time(),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.sun.jna.NativeLibrary
|
||||
import uk.co.caprica.vlcj.binding.lib.LibC
|
||||
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
|
||||
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Discovers bundled VLC libraries on macOS.
|
||||
* Must force-load libvlccore before libvlc to avoid link errors.
|
||||
*/
|
||||
class MacOsVlcDiscoverer :
|
||||
BaseNativeDiscoveryStrategy(
|
||||
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
|
||||
arrayOf("%s/plugins"),
|
||||
) {
|
||||
override fun supported(): Boolean {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
return "mac" in os
|
||||
}
|
||||
|
||||
override fun discoveryDirectories(): List<String> {
|
||||
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return emptyList()
|
||||
val vlcDir = File(resourcesDir, "vlc")
|
||||
return if (vlcDir.isDirectory) listOf(vlcDir.absolutePath) else emptyList()
|
||||
}
|
||||
|
||||
override fun onFound(path: String): Boolean {
|
||||
NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path)
|
||||
NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.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
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
object VideoThumbnailCache {
|
||||
private val cache = ConcurrentHashMap<String, ImageBitmap>()
|
||||
private val pending = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
fun getCached(url: String): ImageBitmap? = cache[url]
|
||||
|
||||
suspend fun getThumbnail(url: String): ImageBitmap? {
|
||||
cache[url]?.let { return it }
|
||||
if (pending.putIfAbsent(url, true) != null) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
extractFirstFrame(url)?.also { cache[url] = it }
|
||||
} finally {
|
||||
pending.remove(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractFirstFrame(url: String): ImageBitmap? {
|
||||
if (!VlcjPlayerPool.init()) {
|
||||
println("VLC thumbnail: init failed for $url")
|
||||
return null
|
||||
}
|
||||
val player = VlcjPlayerPool.acquireForThumbnail()
|
||||
if (player == null) {
|
||||
println("VLC thumbnail: pool exhausted for $url")
|
||||
return null
|
||||
}
|
||||
|
||||
var result: ImageBitmap? = null
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val bufferFormatCallback =
|
||||
object : BufferFormatCallback {
|
||||
override fun getBufferFormat(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight)
|
||||
|
||||
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
|
||||
}
|
||||
|
||||
val renderCallback =
|
||||
RenderCallback { _, nativeBuffers, bufferFormat ->
|
||||
if (result != null) return@RenderCallback
|
||||
try {
|
||||
if (nativeBuffers.isEmpty()) return@RenderCallback
|
||||
val w = bufferFormat.width
|
||||
val h = bufferFormat.height
|
||||
if (w <= 0 || h <= 0) return@RenderCallback
|
||||
val bmp = Bitmap()
|
||||
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
|
||||
val bytes = ByteArray(w * h * 4)
|
||||
val buffer = nativeBuffers[0]
|
||||
buffer.rewind()
|
||||
buffer.get(bytes)
|
||||
bmp.installPixels(bytes)
|
||||
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
||||
latch.countDown()
|
||||
} catch (e: Exception) {
|
||||
println("VLC thumbnail: render error for $url — ${e.message}")
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
|
||||
if (surface == null) {
|
||||
println("VLC thumbnail: surface creation failed for $url")
|
||||
VlcjPlayerPool.release(player)
|
||||
return null
|
||||
}
|
||||
|
||||
player.videoSurface().set(surface)
|
||||
player.audio().setVolume(0)
|
||||
player.audio().isMute = true
|
||||
|
||||
player.events().addMediaPlayerEventListener(
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun error(mediaPlayer: MediaPlayer) {
|
||||
println("VLC thumbnail: playback error for $url")
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
player.media().play(url)
|
||||
|
||||
// Wait up to 8 seconds for first frame (network videos can be slow)
|
||||
latch.await(8, TimeUnit.SECONDS)
|
||||
|
||||
if (result == null) {
|
||||
println("VLC thumbnail: timed out or failed for $url")
|
||||
}
|
||||
|
||||
VlcjPlayerPool.release(player)
|
||||
return result
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
|
||||
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Manages a pool of VLCJ media players to avoid costly create/destroy cycles.
|
||||
* Keeps strong references to prevent GC crashes from native callbacks.
|
||||
*
|
||||
* IMPORTANT: Never let player instances be garbage collected while native
|
||||
* callbacks are active — this causes JVM segfaults.
|
||||
*/
|
||||
object VlcjPlayerPool {
|
||||
private val available = AtomicBoolean(false)
|
||||
private val initAttempted = AtomicBoolean(false)
|
||||
private val initLatch = CountDownLatch(1)
|
||||
private var factory: MediaPlayerFactory? = null
|
||||
|
||||
// Video player pool (for actual playback)
|
||||
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||
private const val MAX_POOL_SIZE = 1
|
||||
|
||||
// Thumbnail player pool (separate so thumbnails don't compete with playback)
|
||||
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||
private const val MAX_THUMB_POOL_SIZE = 2
|
||||
|
||||
// Audio player pool (shared factory with --no-video)
|
||||
private var audioFactory: MediaPlayerFactory? = null
|
||||
private val allAudioPlayers = mutableListOf<MediaPlayer>()
|
||||
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
|
||||
private const val MAX_AUDIO_POOL_SIZE = 1
|
||||
|
||||
/**
|
||||
* Initialize the pool. Thread-safe — only runs once.
|
||||
* Returns false if VLC is not installed.
|
||||
*/
|
||||
fun init(): Boolean {
|
||||
if (available.get()) return true
|
||||
|
||||
// Only one thread performs init; others wait
|
||||
if (!initAttempted.compareAndSet(false, true)) {
|
||||
initLatch.await(10, TimeUnit.SECONDS)
|
||||
return available.get()
|
||||
}
|
||||
|
||||
return try {
|
||||
// Try bundled VLC first, then fall through to system VLC
|
||||
val discovery =
|
||||
try {
|
||||
val nd =
|
||||
NativeDiscovery(
|
||||
BundledVlcDiscoverer(),
|
||||
MacOsVlcDiscoverer(),
|
||||
)
|
||||
val found = nd.discover()
|
||||
if (found) {
|
||||
println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}")
|
||||
} else {
|
||||
println("VLC: bundled discovery failed, falling back to system VLC")
|
||||
}
|
||||
found
|
||||
} catch (e: Throwable) {
|
||||
println("VLC: bundled discovery threw ${e.message}")
|
||||
false
|
||||
}
|
||||
if (!discovery) {
|
||||
// Try default system discovery
|
||||
val systemDiscovery = NativeDiscovery().discover()
|
||||
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
|
||||
}
|
||||
val f = MediaPlayerFactory("--no-xlib")
|
||||
factory = f
|
||||
available.set(true)
|
||||
println("VLC: MediaPlayerFactory created successfully")
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
println("VLC: init failed — ${e.message}")
|
||||
available.set(false)
|
||||
false
|
||||
} finally {
|
||||
initLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = available.get()
|
||||
|
||||
/**
|
||||
* Create a callback video surface using the factory's API.
|
||||
*/
|
||||
fun createVideoSurface(
|
||||
bufferFormatCallback: BufferFormatCallback,
|
||||
renderCallback: RenderCallback,
|
||||
): VideoSurface? {
|
||||
val f = factory ?: return null
|
||||
return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a video player from the pool or create a new one.
|
||||
* Returns null if VLC is not available or pool is at capacity.
|
||||
*/
|
||||
fun acquire(): EmbeddedMediaPlayer? {
|
||||
if (!available.get()) return null
|
||||
val f = factory ?: return null
|
||||
|
||||
synchronized(allPlayers) {
|
||||
idlePlayers.poll()?.let { return it }
|
||||
if (allPlayers.size >= MAX_POOL_SIZE) return null
|
||||
return try {
|
||||
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
|
||||
allPlayers.add(player)
|
||||
player
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a player dedicated to thumbnail extraction.
|
||||
* Separate pool so thumbnails don't compete with playback.
|
||||
*/
|
||||
fun acquireForThumbnail(): EmbeddedMediaPlayer? {
|
||||
if (!available.get()) return null
|
||||
val f = factory ?: return null
|
||||
|
||||
synchronized(allThumbPlayers) {
|
||||
idleThumbPlayers.poll()?.let { return it }
|
||||
if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) {
|
||||
// Fall back to main pool if thumb pool is full
|
||||
return acquire()
|
||||
}
|
||||
return try {
|
||||
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
|
||||
allThumbPlayers.add(player)
|
||||
player
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an audio-only player from the pool.
|
||||
* Uses a separate factory with --no-video for efficiency.
|
||||
*/
|
||||
fun acquireAudioPlayer(): MediaPlayer? {
|
||||
if (!init()) return null
|
||||
|
||||
synchronized(allAudioPlayers) {
|
||||
idleAudioPlayers.poll()?.let { return it }
|
||||
if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null
|
||||
|
||||
val af =
|
||||
audioFactory ?: try {
|
||||
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val player = af.mediaPlayers().newMediaPlayer()
|
||||
allAudioPlayers.add(player)
|
||||
player
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a video player to the pool for reuse.
|
||||
*/
|
||||
fun release(player: EmbeddedMediaPlayer) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
// Return to correct pool
|
||||
synchronized(allThumbPlayers) {
|
||||
if (player in allThumbPlayers) {
|
||||
idleThumbPlayers.offer(player)
|
||||
return
|
||||
}
|
||||
}
|
||||
idlePlayers.offer(player)
|
||||
} catch (_: Exception) {
|
||||
// Player may already be disposed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an audio player to the pool for reuse.
|
||||
*/
|
||||
fun releaseAudioPlayer(player: MediaPlayer) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
idleAudioPlayers.offer(player)
|
||||
} catch (_: Exception) {
|
||||
// Player may already be disposed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the entire pool. Call on app exit.
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(allPlayers) {
|
||||
idlePlayers.clear()
|
||||
for (player in allPlayers) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
player.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allPlayers.clear()
|
||||
}
|
||||
synchronized(allThumbPlayers) {
|
||||
idleThumbPlayers.clear()
|
||||
for (player in allThumbPlayers) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
player.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allThumbPlayers.clear()
|
||||
}
|
||||
synchronized(allAudioPlayers) {
|
||||
idleAudioPlayers.clear()
|
||||
for (player in allAudioPlayers) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
player.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allAudioPlayers.clear()
|
||||
}
|
||||
try {
|
||||
factory?.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
try {
|
||||
audioFactory?.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
factory = null
|
||||
audioFactory = null
|
||||
available.set(false)
|
||||
}
|
||||
}
|
||||
+43
@@ -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"
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
|
||||
class DesktopBlossomClient(
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
suspend fun upload(
|
||||
file: File,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||
val requestBody =
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType.toMediaType()
|
||||
|
||||
override fun contentLength() = file.length()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
file.inputStream().source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
|
||||
val requestBuilder =
|
||||
Request
|
||||
.Builder()
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
val reason = it.headers["X-Reason"] ?: it.code.toString()
|
||||
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
|
||||
*/
|
||||
suspend fun upload(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||
val requestBody = bytes.toRequestBody(contentType.toMediaType())
|
||||
|
||||
val requestBuilder =
|
||||
Request
|
||||
.Builder()
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
val reason = it.headers["X-Reason"] ?: it.code.toString()
|
||||
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class UploadState(
|
||||
val isUploading: Boolean = false,
|
||||
val fileName: String? = null,
|
||||
val error: String? = null,
|
||||
val result: UploadResult? = null,
|
||||
)
|
||||
|
||||
class DesktopUploadTracker {
|
||||
private val _state = MutableStateFlow(UploadState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
fun startUpload(fileName: String) {
|
||||
_state.value = UploadState(isUploading = true, fileName = fileName)
|
||||
}
|
||||
|
||||
fun onSuccess(result: UploadResult) {
|
||||
_state.value = UploadState(isUploading = false, result = result)
|
||||
}
|
||||
|
||||
fun onError(error: String) {
|
||||
_state.value = UploadState(isUploading = false, error = error)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
_state.value = UploadState()
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,9 @@ fun BookmarksScreen(
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
|
||||
+359
-26
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -35,36 +38,117 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
|
||||
import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.io.File
|
||||
|
||||
private val MEDIA_EXTENSIONS =
|
||||
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
|
||||
|
||||
private val IMAGE_EXTENSIONS =
|
||||
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif")
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ComposeNoteDialog(
|
||||
onDismiss: () -> Unit,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
account: AccountState.LoggedIn,
|
||||
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null,
|
||||
replyTo: Event? = null,
|
||||
) {
|
||||
var content by remember { mutableStateOf("") }
|
||||
var isPosting by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachedFiles = remember { mutableStateListOf<File>() }
|
||||
val uploadTracker = remember { DesktopUploadTracker() }
|
||||
val uploadState by uploadTracker.state.collectAsState()
|
||||
val orchestrator = remember { DesktopUploadOrchestrator() }
|
||||
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
|
||||
var postAsPicture by remember { mutableStateOf(false) }
|
||||
|
||||
// Drag-and-drop state
|
||||
var isDragOver by remember { mutableStateOf(false) }
|
||||
val dropTarget =
|
||||
remember {
|
||||
object : DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
isDragOver = false
|
||||
val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false
|
||||
dropEvent.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val transferable = dropEvent.transferable
|
||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
||||
attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS })
|
||||
dropEvent.dropComplete(true)
|
||||
return true
|
||||
}
|
||||
dropEvent.dropComplete(false)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onStarted(event: DragAndDropEvent) {
|
||||
isDragOver = true
|
||||
}
|
||||
|
||||
override fun onEnded(event: DragAndDropEvent) {
|
||||
isDragOver = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) {
|
||||
Card(
|
||||
modifier = Modifier.width(600.dp).padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(600.dp)
|
||||
.padding(16.dp)
|
||||
.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget)
|
||||
.then(
|
||||
if (isDragOver) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Text(
|
||||
@@ -85,20 +169,67 @@ fun ComposeNoteDialog(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = content,
|
||||
value = if (postAsPicture) "" else content,
|
||||
onValueChange = {
|
||||
content = it
|
||||
errorMessage = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||
label = { Text("What's on your mind?") },
|
||||
placeholder = { Text("Write your note...") },
|
||||
enabled = !isPosting,
|
||||
maxLines = 10,
|
||||
modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
|
||||
label = {
|
||||
Text(
|
||||
if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
|
||||
)
|
||||
},
|
||||
placeholder = { Text(if (postAsPicture) "" else "Write your note...") },
|
||||
enabled = !isPosting && !postAsPicture,
|
||||
maxLines = if (postAsPicture) 1 else 10,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
MediaAttachmentRow(
|
||||
attachedFiles = attachedFiles,
|
||||
isUploading = uploadState.isUploading,
|
||||
onAttach = {
|
||||
val files = DesktopFilePicker.pickMediaFiles()
|
||||
attachedFiles.addAll(files)
|
||||
},
|
||||
onPaste = {
|
||||
val files = ClipboardPasteHandler.getClipboardFiles()
|
||||
attachedFiles.addAll(files)
|
||||
},
|
||||
onRemove = { attachedFiles.remove(it) },
|
||||
)
|
||||
|
||||
// Server selector + post type — shown when files are attached
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
ServerSelector(
|
||||
selectedServer = selectedServer,
|
||||
onServerSelected = { selectedServer = it },
|
||||
)
|
||||
|
||||
// Post type toggle — only when images are attached
|
||||
val hasImages =
|
||||
attachedFiles.any {
|
||||
it.extension.lowercase() in IMAGE_EXTENSIONS
|
||||
}
|
||||
if (hasImages) {
|
||||
PostTypeSelector(
|
||||
isPicture = postAsPicture,
|
||||
onToggle = { postAsPicture = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Character count
|
||||
Text(
|
||||
"${content.length} characters",
|
||||
@@ -115,6 +246,15 @@ fun ComposeNoteDialog(
|
||||
)
|
||||
}
|
||||
|
||||
uploadState.error?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Upload error: $error",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
@@ -132,7 +272,7 @@ fun ComposeNoteDialog(
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (content.isBlank()) {
|
||||
if (content.isBlank() && attachedFiles.isEmpty()) {
|
||||
errorMessage = "Note cannot be empty"
|
||||
return@Button
|
||||
}
|
||||
@@ -142,21 +282,61 @@ fun ComposeNoteDialog(
|
||||
errorMessage = null
|
||||
|
||||
try {
|
||||
publishNote(
|
||||
content = content,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
replyTo = replyTo,
|
||||
)
|
||||
// Upload attached files and collect results
|
||||
val uploadResults = mutableListOf<UploadResult>()
|
||||
for (file in attachedFiles) {
|
||||
uploadTracker.startUpload(file.name)
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = selectedServer,
|
||||
signer = account.signer,
|
||||
)
|
||||
uploadTracker.onSuccess(result)
|
||||
uploadResults.add(result)
|
||||
}
|
||||
|
||||
// Append uploaded URLs to content
|
||||
val finalContent =
|
||||
buildString {
|
||||
append(content)
|
||||
for (result in uploadResults) {
|
||||
result.blossom.url?.let { url ->
|
||||
if (isNotBlank()) append("\n")
|
||||
append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postAsPicture) {
|
||||
val pictureMetas = buildPictureMetas(uploadResults)
|
||||
publishPicture(
|
||||
description = content,
|
||||
images = pictureMetas,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
)
|
||||
} else {
|
||||
val imetaTags = buildIMetaTags(uploadResults)
|
||||
publishNote(
|
||||
content = finalContent,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
replyTo = replyTo,
|
||||
imetaTags = imetaTags,
|
||||
)
|
||||
}
|
||||
onDismiss()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "Failed to publish: ${e.message}"
|
||||
errorMessage = "Failed: ${e.message}"
|
||||
uploadTracker.onError(e.message ?: "Unknown error")
|
||||
} finally {
|
||||
isPosting = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isPosting && content.isNotBlank(),
|
||||
enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()),
|
||||
) {
|
||||
Text(if (isPosting) "Publishing..." else "Publish")
|
||||
}
|
||||
@@ -166,23 +346,176 @@ fun ComposeNoteDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a text note to relays.
|
||||
* Uses the Account's key to sign the event.
|
||||
*/
|
||||
private suspend fun publishNote(
|
||||
content: String,
|
||||
private fun buildIMetaTags(results: List<UploadResult>): List<IMetaTag> =
|
||||
results.mapNotNull { result ->
|
||||
val url = result.blossom.url ?: return@mapNotNull null
|
||||
val meta = result.metadata
|
||||
val props = mutableMapOf<String, List<String>>()
|
||||
props["m"] = listOf(meta.mimeType)
|
||||
props["x"] = listOf(meta.sha256)
|
||||
props["size"] = listOf(meta.size.toString())
|
||||
if (meta.width != null && meta.height != null) {
|
||||
props["dim"] = listOf("${meta.width}x${meta.height}")
|
||||
}
|
||||
meta.blurhash?.let { props["blurhash"] = listOf(it) }
|
||||
IMetaTag(url = url, properties = props)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerSelector(
|
||||
selectedServer: String,
|
||||
onServerSelected: (String) -> Unit,
|
||||
) {
|
||||
val servers = DesktopPreferences.blossomServers
|
||||
if (servers.size <= 1) {
|
||||
// Only one server — just show label, no dropdown
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Upload to: ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
selectedServer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Upload to: ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.foundation.layout.Box {
|
||||
androidx.compose.material3.TextButton(onClick = { expanded = true }) {
|
||||
Text(
|
||||
selectedServer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
servers.forEach { server ->
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text(server, style = MaterialTheme.typography.bodySmall) },
|
||||
onClick = {
|
||||
onServerSelected(server)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PostTypeSelector(
|
||||
isPicture: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"Post as:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = !isPicture,
|
||||
onClick = { onToggle(false) },
|
||||
label = { Text("Note", style = MaterialTheme.typography.labelSmall) },
|
||||
)
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = isPicture,
|
||||
onClick = { onToggle(true) },
|
||||
label = { Text("Picture", style = MaterialTheme.typography.labelSmall) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPictureMetas(results: List<UploadResult>): List<com.vitorpamplona.quartz.nip68Picture.PictureMeta> =
|
||||
results.mapNotNull { result ->
|
||||
val url = result.blossom.url ?: return@mapNotNull null
|
||||
val meta = result.metadata
|
||||
com.vitorpamplona.quartz.nip68Picture.PictureMeta(
|
||||
url = url,
|
||||
mimeType = meta.mimeType,
|
||||
blurhash = meta.blurhash,
|
||||
dimension =
|
||||
if (meta.width != null && meta.height != null) {
|
||||
com.vitorpamplona.quartz.nip94FileMetadata.tags
|
||||
.DimensionTag(meta.width, meta.height)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
hash = meta.sha256,
|
||||
size = meta.size.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publishPicture(
|
||||
description: String,
|
||||
images: List<com.vitorpamplona.quartz.nip68Picture.PictureMeta>,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (account.isReadOnly) {
|
||||
throw IllegalStateException("Cannot post in read-only mode")
|
||||
}
|
||||
|
||||
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip68Picture.PictureEvent.build(
|
||||
images = images,
|
||||
description = description,
|
||||
) {
|
||||
hashtags(findHashtags(description))
|
||||
}
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publishNote(
|
||||
content: String,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
replyTo: Event?,
|
||||
imetaTags: List<IMetaTag> = emptyList(),
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (account.isReadOnly) {
|
||||
throw IllegalStateException("Cannot post in read-only mode")
|
||||
}
|
||||
|
||||
val template =
|
||||
TextNoteEvent.build(content) {
|
||||
if (replyTo != null) {
|
||||
val etag = ETag(replyTo.id)
|
||||
etag.relay = null
|
||||
etag.author = replyTo.pubKey
|
||||
eTag(etag)
|
||||
pTag(PTag(replyTo.pubKey, relayHint = null))
|
||||
}
|
||||
hashtags(findHashtags(content))
|
||||
references(findURLs(content))
|
||||
for (imeta in imetaTags) {
|
||||
add(imeta.toTagArray())
|
||||
}
|
||||
}
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
+187
-144
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
@@ -75,7 +76,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
@@ -86,6 +89,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
data class LightboxState(
|
||||
val urls: List<String>,
|
||||
val index: Int,
|
||||
val seekPosition: Float = 0f,
|
||||
val fullscreen: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Note card with action buttons.
|
||||
*/
|
||||
@@ -100,6 +110,8 @@ fun FeedNoteCard(
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||
zapReceipts: List<ZapReceipt> = emptyList(),
|
||||
reactionCount: Int = 0,
|
||||
replyCount: Int = 0,
|
||||
@@ -110,15 +122,15 @@ fun FeedNoteCard(
|
||||
) {
|
||||
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
)
|
||||
|
||||
// Action buttons (only if logged in)
|
||||
@@ -180,6 +192,7 @@ fun FeedScreen(
|
||||
}
|
||||
val events by eventState.items.collectAsState()
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
|
||||
@@ -434,30 +447,40 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to metadata for note authors (to enable zaps and populate search cache)
|
||||
// Subscribe to metadata for note authors + mentioned users
|
||||
val authorPubkeys = events.map { it.pubKey }.distinct()
|
||||
val mentionedPubkeys =
|
||||
remember(events) {
|
||||
val parser = UrlParser()
|
||||
events
|
||||
.flatMap { event ->
|
||||
val urls = parser.parseValidUrls(event.content)
|
||||
extractMentionedPubkeys(urls.bech32s)
|
||||
}.distinct()
|
||||
}
|
||||
val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() }
|
||||
|
||||
// Use coordinator for rate-limited metadata loading (preferred)
|
||||
LaunchedEffect(authorPubkeys, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys)
|
||||
LaunchedEffect(allPubkeys, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback subscription if coordinator not available
|
||||
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
// Skip if using coordinator
|
||||
if (subscriptionsCoordinator != null) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
|
||||
if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
// Only fetch metadata for users we don't have yet
|
||||
val missingPubkeys =
|
||||
authorPubkeys.filter { pubkey ->
|
||||
allPubkeys.filter { pubkey ->
|
||||
localCache
|
||||
.getUserIfExists(pubkey)
|
||||
?.metadataOrNull()
|
||||
@@ -480,147 +503,156 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with compose button — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with compose button — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
// Feed mode selector
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = {
|
||||
feedMode = FeedMode.GLOBAL
|
||||
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
||||
},
|
||||
label = { Text("Global") },
|
||||
// Feed mode selector
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = {
|
||||
feedMode = FeedMode.GLOBAL
|
||||
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
||||
},
|
||||
label = { Text("Global") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = {
|
||||
feedMode = FeedMode.FOLLOWING
|
||||
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
||||
},
|
||||
label = { Text("Following") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
" • ${followedUsers.size} followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = {
|
||||
feedMode = FeedMode.FOLLOWING
|
||||
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
||||
},
|
||||
label = { Text("Following") },
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { relayManager.connect() },
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
" • ${followedUsers.size} followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// New Post button (primary action)
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { relayManager.connect() },
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||
LoadingState("Loading followed users...")
|
||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
||||
LoadingState("Loading notes...")
|
||||
} else if (events.isEmpty() && initialLoadComplete) {
|
||||
EmptyState(
|
||||
title =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"No notes from followed users"
|
||||
} else {
|
||||
"No notes found"
|
||||
},
|
||||
description =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"Notes from people you follow will appear here"
|
||||
} else {
|
||||
"Notes from the network will appear here"
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { replyToEvent = event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New Post button (primary action)
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||
LoadingState("Loading followed users...")
|
||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
||||
LoadingState("Loading notes...")
|
||||
} else if (events.isEmpty() && initialLoadComplete) {
|
||||
EmptyState(
|
||||
title =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"No notes from followed users"
|
||||
} else {
|
||||
"No notes found"
|
||||
},
|
||||
description =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"Notes from people you follow will appear here"
|
||||
} else {
|
||||
"Notes from the network will appear here"
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { replyToEvent = event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// Reply dialog
|
||||
if (replyToEvent != null && account != null) {
|
||||
@@ -631,5 +663,16 @@ fun FeedScreen(
|
||||
replyTo = replyToEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
lightboxState?.let { state ->
|
||||
LightboxOverlay(
|
||||
urls = state.urls,
|
||||
initialIndex = state.index,
|
||||
initialSeekPosition = state.seekPosition,
|
||||
initialFullscreen = state.fullscreen,
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
|
||||
@@ -449,6 +449,7 @@ fun SearchScreen(
|
||||
state = state,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
localCache = localCache,
|
||||
)
|
||||
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
||||
Text(
|
||||
|
||||
+192
-148
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -51,6 +52,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
@@ -68,7 +70,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
@@ -135,12 +139,25 @@ fun ThreadScreen(
|
||||
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
||||
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
// Load metadata for thread authors via coordinator
|
||||
// Lightbox state
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
|
||||
// Load metadata for thread authors + mentioned users via coordinator
|
||||
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null) {
|
||||
val pubkeys = mutableListOf<String>()
|
||||
rootNote?.let { pubkeys.add(it.pubKey) }
|
||||
pubkeys.addAll(replyEvents.map { it.pubKey })
|
||||
|
||||
// Also load metadata for users mentioned in note content
|
||||
val parser = UrlParser()
|
||||
val allEvents = listOfNotNull(rootNote) + replyEvents
|
||||
val mentionedPubkeys =
|
||||
allEvents.flatMap { event ->
|
||||
extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s)
|
||||
}
|
||||
pubkeys.addAll(mentionedPubkeys)
|
||||
|
||||
if (pubkeys.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
|
||||
}
|
||||
@@ -331,173 +348,200 @@ fun ThreadScreen(
|
||||
return level
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with back button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(24.dp),
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with back button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Thread",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Thread",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (rootNote == null && !rootNoteEoseReceived) {
|
||||
LoadingState("Loading thread...")
|
||||
} else if (rootNote == null && rootNoteEoseReceived) {
|
||||
EmptyState(
|
||||
title = "Note not found",
|
||||
description = "This note may have been deleted or is not available from connected relays",
|
||||
onRefresh = onBack,
|
||||
refreshLabel = "Go back",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Root note (no reply level indicator)
|
||||
item(key = noteId) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
// Already viewing this thread, no-op
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = rootNote!!.toNoteDisplayData(localCache),
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
if (account != null) {
|
||||
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = rootNote!!,
|
||||
relayManager = relayManager,
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (rootNote == null && !rootNoteEoseReceived) {
|
||||
LoadingState("Loading thread...")
|
||||
} else if (rootNote == null && rootNoteEoseReceived) {
|
||||
EmptyState(
|
||||
title = "Note not found",
|
||||
description = "This note may have been deleted or is not available from connected relays",
|
||||
onRefresh = onBack,
|
||||
refreshLabel = "Go back",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Root note (no reply level indicator)
|
||||
item(key = noteId) {
|
||||
Column {
|
||||
NoteCard(
|
||||
note = rootNote!!.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(rootNote!!) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = rootZaps.size,
|
||||
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
||||
zapReceipts = rootZaps,
|
||||
reactionCount = reactionsByEvent[noteId] ?: 0,
|
||||
replyCount = repliesByEvent[noteId] ?: 0,
|
||||
repostCount = repostsByEvent[noteId] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(noteId),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (account != null) {
|
||||
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = rootNote!!,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(rootNote!!) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = rootZaps.size,
|
||||
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
||||
zapReceipts = rootZaps,
|
||||
reactionCount = reactionsByEvent[noteId] ?: 0,
|
||||
replyCount = repliesByEvent[noteId] ?: 0,
|
||||
repostCount = repostsByEvent[noteId] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(noteId),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected =
|
||||
if (event.id == noteId) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
).clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
if (account != null) {
|
||||
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected =
|
||||
if (event.id == noteId) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
).clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(event) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = eventZaps.size,
|
||||
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
||||
zapReceipts = eventZaps,
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (account != null) {
|
||||
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(event) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = eventZaps.size,
|
||||
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
||||
zapReceipts = eventZaps,
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Empty state for no replies
|
||||
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"Loading replies...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
// Empty state for no replies
|
||||
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"Loading replies...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// Lightbox overlay
|
||||
val lb = lightboxState
|
||||
if (lb != null) {
|
||||
LightboxOverlay(
|
||||
urls = lb.urls,
|
||||
initialIndex = lb.index,
|
||||
initialSeekPosition = lb.seekPosition,
|
||||
initialFullscreen = lb.fullscreen,
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+450
-304
@@ -20,6 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
@@ -49,12 +54,15 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -79,12 +87,16 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -141,6 +153,11 @@ fun UserProfileScreen(
|
||||
var postsError by remember { mutableStateOf<String?>(null) }
|
||||
var retryTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// Tab and gallery state
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
|
||||
|
||||
// Follow state
|
||||
val followState =
|
||||
remember(account) {
|
||||
@@ -300,343 +317,472 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Broadcast banner for profile updates
|
||||
ProfileBroadcastBanner(
|
||||
status = broadcastStatus,
|
||||
onTap = {
|
||||
// Clear banner on tap (could add retry logic for failed)
|
||||
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
||||
broadcastStatus is ProfileBroadcastStatus.Failed
|
||||
) {
|
||||
broadcastStatus = ProfileBroadcastStatus.Idle
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Header with back button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Profile",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
|
||||
// Edit button for own profile
|
||||
if (isOwnProfile && account.isReadOnly == false) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
editingDisplayName = displayName ?: ""
|
||||
showEditDialog = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit profile",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Edit Profile")
|
||||
}
|
||||
}
|
||||
|
||||
// Follow/Unfollow button for other profiles
|
||||
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val currentStatus = followState.currentStatusOrNull()
|
||||
|
||||
followState.setFollowLoading()
|
||||
try {
|
||||
val updatedEvent =
|
||||
if (currentStatus?.isFollowing == true) {
|
||||
unfollowUser(pubKeyHex, account, relayManager, myContactList)
|
||||
} else {
|
||||
followUser(pubKeyHex, account, relayManager, myContactList)
|
||||
}
|
||||
|
||||
// Update both stored contact list and followState
|
||||
myContactList = updatedEvent
|
||||
followState.setFollowSuccess(updatedEvent, pubKeyHex)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
followState.setFollowError(e.message ?: "Failed to update follow status", e)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
||||
) {
|
||||
val state = followState.state.collectAsState().value
|
||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
||||
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
|
||||
|
||||
when {
|
||||
!contactListLoaded -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
isLoading -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollowing..." else "Following...")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Icon(
|
||||
if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
|
||||
contentDescription = if (isFollowing) "Unfollow" else "Follow",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollow" else "Follow")
|
||||
}
|
||||
}
|
||||
// Subscribe to picture events (kind 20) for gallery tab
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
pictureEvents.clear()
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("pics-${pubKeyHex.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byAuthors(
|
||||
authors = listOf(pubKeyHex),
|
||||
kinds = listOf(PictureEvent.KIND),
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is PictureEvent && pictureEvents.none { it.id == event.id }) {
|
||||
pictureEvents.add(event)
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
followState.state
|
||||
.collectAsState()
|
||||
.value
|
||||
.errorOrNull()
|
||||
errorMessage?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll state for detecting scroll direction
|
||||
val listState = rememberLazyListState()
|
||||
var showFloatingHeader by remember { mutableStateOf(false) }
|
||||
var previousFirstVisibleItemIndex by remember { mutableStateOf(0) }
|
||||
var previousFirstVisibleItemScrollOffset by remember { mutableStateOf(0) }
|
||||
|
||||
// Show floating header when scrolling up and header is scrolled out of view
|
||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
val currentIndex = listState.firstVisibleItemIndex
|
||||
val currentOffset = listState.firstVisibleItemScrollOffset
|
||||
val scrollingUp =
|
||||
currentIndex < previousFirstVisibleItemIndex ||
|
||||
(currentIndex == previousFirstVisibleItemIndex && currentOffset < previousFirstVisibleItemScrollOffset)
|
||||
|
||||
// Header items are indices 0-3, so if first visible >= 3, header is out of view
|
||||
showFloatingHeader = scrollingUp && currentIndex >= 3
|
||||
if (!scrollingUp && currentIndex < 3) showFloatingHeader = false
|
||||
|
||||
previousFirstVisibleItemIndex = currentIndex
|
||||
previousFirstVisibleItemScrollOffset = currentOffset
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else {
|
||||
// Profile card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Broadcast banner
|
||||
item(key = "broadcast") {
|
||||
ProfileBroadcastBanner(
|
||||
status = broadcastStatus,
|
||||
onTap = {
|
||||
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
||||
broadcastStatus is ProfileBroadcastStatus.Failed
|
||||
) {
|
||||
broadcastStatus = ProfileBroadcastStatus.Idle
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Header with back button
|
||||
item(key = "header") {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Profile picture with robohash fallback
|
||||
UserAvatar(
|
||||
userHex = pubKeyHex,
|
||||
pictureUrl = picture,
|
||||
size = 56.dp,
|
||||
contentDescription = "Profile picture",
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
"Profile",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
}
|
||||
|
||||
// Reset copied state after delay
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
// Edit button for own profile
|
||||
if (isOwnProfile && account.isReadOnly == false) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
editingDisplayName = displayName ?: ""
|
||||
showEditDialog = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit profile",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Edit Profile")
|
||||
}
|
||||
}
|
||||
|
||||
// Follow/Unfollow button for other profiles
|
||||
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val currentStatus = followState.currentStatusOrNull()
|
||||
|
||||
followState.setFollowLoading()
|
||||
try {
|
||||
val updatedEvent =
|
||||
if (currentStatus?.isFollowing == true) {
|
||||
unfollowUser(pubKeyHex, account, relayManager, myContactList)
|
||||
} else {
|
||||
followUser(pubKeyHex, account, relayManager, myContactList)
|
||||
}
|
||||
|
||||
// Update both stored contact list and followState
|
||||
myContactList = updatedEvent
|
||||
followState.setFollowSuccess(updatedEvent, pubKeyHex)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
followState.setFollowError(e.message ?: "Failed to update follow status", e)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
||||
) {
|
||||
val state = followState.state.collectAsState().value
|
||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
||||
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
|
||||
|
||||
when {
|
||||
!contactListLoaded -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
isLoading -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollowing..." else "Following...")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Icon(
|
||||
if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
|
||||
contentDescription = if (isFollowing) "Unfollow" else "Follow",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollow" else "Follow")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
followState.state
|
||||
.collectAsState()
|
||||
.value
|
||||
.errorOrNull()
|
||||
errorMessage?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profile card
|
||||
item(key = "profile-card") {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = pubKeyHex,
|
||||
pictureUrl = picture,
|
||||
size = 56.dp,
|
||||
contentDescription = "Profile picture",
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (npub != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
clipboard.setContents(StringSelection(npub), null)
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy npub",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint =
|
||||
if (copied) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (about != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
about!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (npub != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
clipboard.setContents(StringSelection(npub), null)
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column {
|
||||
Text(
|
||||
"$followersCount",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Followers",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
"$followingCount",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Following",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs
|
||||
item(key = "tabs") {
|
||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
||||
Text("Notes", modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
|
||||
Text("Gallery", modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
when (selectedTab) {
|
||||
0 -> {
|
||||
when {
|
||||
postsError != null -> {
|
||||
item(key = "error") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy npub",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint =
|
||||
if (copied) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Failed to load posts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
postsError!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = { retryTrigger++ }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postsLoading -> {
|
||||
item(key = "loading") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.material3.CircularProgressIndicator()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Loading posts...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.isEmpty() -> {
|
||||
item(key = "empty") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"No posts yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (about != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
about!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column {
|
||||
Text(
|
||||
"$followersCount",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Followers",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
"$followingCount",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Following",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// User's posts
|
||||
Text(
|
||||
"Posts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
postsError != null -> {
|
||||
// Error state with retry
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Failed to load posts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
postsError!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = { retryTrigger++ }) {
|
||||
Text("Retry")
|
||||
else -> {
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onCompose,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onImageClick = { urls, index ->
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postsLoading -> {
|
||||
// Loading state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.material3.CircularProgressIndicator()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Loading posts...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.isEmpty() -> {
|
||||
// Empty state (loaded but no posts)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"No posts yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Posts loaded successfully
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onCompose,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
1 -> {
|
||||
item(key = "gallery") {
|
||||
GalleryTab(
|
||||
pictureEvents = pictureEvents,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
modifier = Modifier.fillParentMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating header — appears on scroll up when profile header is out of view
|
||||
AnimatedVisibility(
|
||||
visible = showFloatingHeader,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
UserAvatar(
|
||||
userHex = pubKeyHex,
|
||||
pictureUrl = picture,
|
||||
size = 28.dp,
|
||||
contentDescription = "Profile picture",
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
displayName ?: pubKeyHex.take(12) + "...",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
lightboxState?.let { state ->
|
||||
LightboxOverlay(
|
||||
urls = state.urls,
|
||||
initialIndex = state.index,
|
||||
initialSeekPosition = state.seekPosition,
|
||||
initialFullscreen = state.fullscreen,
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Profile Dialog
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Forward
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
@Composable
|
||||
fun ChatFileAttachment(
|
||||
event: ChatMessageEncryptedFileHeaderEvent,
|
||||
onForward: ((ChatMessageEncryptedFileHeaderEvent) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val url = event.url()
|
||||
val mimeType = event.mimeType()
|
||||
val keyBytes = event.key()
|
||||
val nonce = event.nonce()
|
||||
val isImage = mimeType?.startsWith("image/") == true
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// Auto-decrypt images
|
||||
LaunchedEffect(url) {
|
||||
if (keyBytes != null && nonce != null && url != null) {
|
||||
isLoading = true
|
||||
try {
|
||||
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
|
||||
decryptedBytes = bytes
|
||||
if (isImage) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val skImage = SkiaImage.makeFromEncoded(bytes)
|
||||
decryptedImage = skImage.toComposeImageBitmap()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
// Right-click detection handled via awaiting press
|
||||
// Context menu is shown on secondary button via the other modifier
|
||||
},
|
||||
)
|
||||
}.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.buttons.isSecondaryPressed &&
|
||||
event.changes.any { it.pressed && !it.previousPressed }
|
||||
) {
|
||||
showContextMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
// Encryption indicator
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "Encrypted",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"Encrypted file",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
when {
|
||||
isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
|
||||
decryptedImage != null -> {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = decryptedImage!!,
|
||||
contentDescription = "Encrypted image",
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
error != null -> {
|
||||
Text(
|
||||
"Failed to decrypt: $error",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Non-image file
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = "File",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
mimeType ?: "Unknown file",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
event.size()?.let { size ->
|
||||
Text(
|
||||
"${size / 1024}KB",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click context menu
|
||||
DropdownMenu(
|
||||
expanded = showContextMenu,
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save to disk") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
enabled = decryptedBytes != null,
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
scope.launch {
|
||||
saveDecryptedFile(decryptedBytes!!, mimeType, event.hash())
|
||||
}
|
||||
},
|
||||
)
|
||||
if (onForward != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Forward") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onForward(event)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save decrypted bytes to disk via a native save dialog.
|
||||
*/
|
||||
private suspend fun saveDecryptedFile(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
hash: String?,
|
||||
) {
|
||||
val extension = mimeTypeToExtension(mimeType)
|
||||
val suggestedName = "${hash?.take(12) ?: "file"}.$extension"
|
||||
|
||||
val file =
|
||||
withContext(Dispatchers.Main) {
|
||||
val dialog =
|
||||
FileDialog(null as Frame?, "Save Decrypted File", FileDialog.SAVE).apply {
|
||||
this.file = suggestedName
|
||||
}
|
||||
dialog.isVisible = true
|
||||
val dir = dialog.directory ?: return@withContext null
|
||||
File(dir, dialog.file ?: return@withContext null)
|
||||
} ?: return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeToExtension(mimeType: String?): String =
|
||||
when (mimeType) {
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/gif" -> "gif"
|
||||
"image/webp" -> "webp"
|
||||
"image/svg+xml" -> "svg"
|
||||
"video/mp4" -> "mp4"
|
||||
"video/webm" -> "webm"
|
||||
"video/quicktime" -> "mov"
|
||||
"audio/mpeg" -> "mp3"
|
||||
"audio/ogg" -> "ogg"
|
||||
"audio/wav" -> "wav"
|
||||
"audio/flac" -> "flac"
|
||||
else -> "bin"
|
||||
}
|
||||
+345
-103
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -37,7 +39,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
@@ -46,6 +50,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -53,6 +59,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -60,6 +67,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
@@ -88,14 +97,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.io.File
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
private val MEDIA_EXTENSIONS =
|
||||
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
|
||||
|
||||
/**
|
||||
* Right panel of the DM split-pane layout (flexible width).
|
||||
*
|
||||
@@ -111,6 +134,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
* @param messageState ChatNewMessageState for composition
|
||||
* @param onNavigateToProfile Called when user clicks on a profile
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ChatPane(
|
||||
roomKey: ChatroomKey,
|
||||
@@ -120,6 +144,7 @@ fun ChatPane(
|
||||
messageState: ChatNewMessageState,
|
||||
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -128,6 +153,55 @@ fun ChatPane(
|
||||
val isNip17 by messageState.nip17.collectAsState()
|
||||
val requiresNip17 by messageState.requiresNip17.collectAsState()
|
||||
|
||||
// File attachment state
|
||||
val attachedFiles = remember { mutableStateListOf<File>() }
|
||||
var isUploading by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Helper: attach files and auto-force NIP-17 if needed
|
||||
fun attachFiles(files: List<File>) {
|
||||
val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }
|
||||
if (mediaFiles.isEmpty()) return
|
||||
attachedFiles.addAll(mediaFiles)
|
||||
if (!isNip17) {
|
||||
messageState.toggleNip17()
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("Switched to NIP-17 — file attachments require encrypted messaging")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop target for file attachments (NIP-17 only)
|
||||
var isDragOver by remember { mutableStateOf(false) }
|
||||
val dropTarget =
|
||||
remember {
|
||||
object : DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
isDragOver = false
|
||||
val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false
|
||||
dropEvent.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val transferable = dropEvent.transferable
|
||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
||||
attachFiles(files)
|
||||
dropEvent.dropComplete(true)
|
||||
return true
|
||||
}
|
||||
dropEvent.dropComplete(false)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onStarted(event: DragAndDropEvent) {
|
||||
isDragOver = true
|
||||
}
|
||||
|
||||
override fun onEnded(event: DragAndDropEvent) {
|
||||
isDragOver = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve users for the header
|
||||
val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
|
||||
val isGroup = users.size > 1
|
||||
@@ -137,110 +211,186 @@ fun ChatPane(
|
||||
messageState.load(roomKey)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
// Header
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
users = users,
|
||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||
)
|
||||
} else {
|
||||
users.firstOrNull()?.let { user ->
|
||||
ChatroomHeader(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
} ?: run {
|
||||
// Fallback header with raw pubkey
|
||||
Text(
|
||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Broadcast status banner
|
||||
DmBroadcastBanner(status = dmBroadcastStatus)
|
||||
|
||||
// Message list
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (feedState) {
|
||||
is FeedState.Loading -> {
|
||||
LoadingState("Loading messages...")
|
||||
}
|
||||
|
||||
is FeedState.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"No messages yet. Send the first one!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
val loaded = feedState as FeedState.Loaded
|
||||
val loadedState by loaded.feed.collectAsState()
|
||||
val messages = loadedState.list
|
||||
|
||||
MessageList(
|
||||
messages = messages,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onReaction = { note, emoji ->
|
||||
scope.launch {
|
||||
try {
|
||||
sendWrappedReaction(note, emoji, roomKey, account)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to send reaction: ${e.message}")
|
||||
}
|
||||
}
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.dragAndDropTarget(
|
||||
shouldStartDragAndDrop = { true },
|
||||
target = dropTarget,
|
||||
).then(
|
||||
if (isDragOver) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Text(
|
||||
"Error loading messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to conversations",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Message input
|
||||
MessageInput(
|
||||
messageText = messageText.text,
|
||||
isNip17 = isNip17,
|
||||
requiresNip17 = requiresNip17,
|
||||
canSend = messageState.canSend,
|
||||
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
||||
onToggleNip17 = { messageState.toggleNip17() },
|
||||
onSend = {
|
||||
scope.launch {
|
||||
if (messageState.send()) {
|
||||
messageState.clear()
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
users = users,
|
||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||
)
|
||||
} else {
|
||||
users.firstOrNull()?.let { user ->
|
||||
ChatroomHeader(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
} ?: run {
|
||||
// Fallback header with raw pubkey
|
||||
Text(
|
||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Broadcast status banner
|
||||
DmBroadcastBanner(status = dmBroadcastStatus)
|
||||
|
||||
// Message list
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (feedState) {
|
||||
is FeedState.Loading -> {
|
||||
LoadingState("Loading messages...")
|
||||
}
|
||||
|
||||
is FeedState.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"No messages yet. Send the first one!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
val loaded = feedState as FeedState.Loaded
|
||||
val loadedState by loaded.feed.collectAsState()
|
||||
val messages = loadedState.list
|
||||
|
||||
MessageList(
|
||||
messages = messages,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onReaction = { note, emoji ->
|
||||
scope.launch {
|
||||
try {
|
||||
sendWrappedReaction(note, emoji, roomKey, account)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to send reaction: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Error loading messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// File attachment row (only when NIP-17 and files attached)
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
MediaAttachmentRow(
|
||||
attachedFiles = attachedFiles,
|
||||
isUploading = isUploading,
|
||||
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
|
||||
onPaste = {},
|
||||
onRemove = { attachedFiles.remove(it) },
|
||||
)
|
||||
}
|
||||
|
||||
// Message input
|
||||
MessageInput(
|
||||
messageText = messageText.text,
|
||||
isNip17 = isNip17,
|
||||
requiresNip17 = requiresNip17,
|
||||
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
|
||||
isUploading = isUploading,
|
||||
hasAttachments = attachedFiles.isNotEmpty(),
|
||||
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
||||
onToggleNip17 = { messageState.toggleNip17() },
|
||||
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
|
||||
onSend = {
|
||||
scope.launch {
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
isUploading = true
|
||||
try {
|
||||
sendEncryptedFiles(
|
||||
files = attachedFiles.toList(),
|
||||
roomKey = roomKey,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
)
|
||||
attachedFiles.clear()
|
||||
} catch (e: Exception) {
|
||||
// Keep files in attachment row for retry
|
||||
println("Encrypted file send failed: ${e.message}")
|
||||
} finally {
|
||||
isUploading = false
|
||||
}
|
||||
}
|
||||
// Also send text message if present
|
||||
if (messageState.canSend) {
|
||||
if (messageState.send()) {
|
||||
messageState.clear()
|
||||
}
|
||||
} else if (attachedFiles.isEmpty()) {
|
||||
messageState.clear()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} // end Column
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp),
|
||||
)
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,6 +523,29 @@ private fun MessageWithReactions(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Encryption badge
|
||||
when (event) {
|
||||
is PrivateDmEvent -> {
|
||||
Icon(
|
||||
Icons.Default.LockOpen,
|
||||
contentDescription = "NIP-04 (legacy)",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent,
|
||||
is ChatMessageEncryptedFileHeaderEvent,
|
||||
-> {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "NIP-17 (encrypted)",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
note.createdAt()?.let { timestamp ->
|
||||
Text(
|
||||
@@ -436,12 +609,20 @@ private fun MessageWithReactions(
|
||||
}
|
||||
},
|
||||
) { _ ->
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = decryptedContent ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
when (note.event) {
|
||||
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||
ChatFileAttachment(event = note.event as ChatMessageEncryptedFileHeaderEvent)
|
||||
}
|
||||
|
||||
else -> {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = decryptedContent ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,8 +701,11 @@ private fun MessageInput(
|
||||
isNip17: Boolean,
|
||||
requiresNip17: Boolean,
|
||||
canSend: Boolean,
|
||||
isUploading: Boolean = false,
|
||||
hasAttachments: Boolean = false,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onToggleNip17: () -> Unit,
|
||||
onAttach: () -> Unit = {},
|
||||
onSend: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
@@ -530,6 +714,24 @@ private fun MessageInput(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Paperclip attach button — always visible, auto-switches to NIP-17 on send
|
||||
IconButton(
|
||||
onClick = onAttach,
|
||||
enabled = !isUploading,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach file",
|
||||
tint =
|
||||
if (isUploading) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = messageText,
|
||||
onValueChange = onMessageChange,
|
||||
@@ -555,14 +757,14 @@ private fun MessageInput(
|
||||
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
enabled = canSend && !isUploading,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint =
|
||||
if (canSend) {
|
||||
if (canSend && !isUploading) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
@@ -616,3 +818,43 @@ private fun MessageInput(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15)
|
||||
* wrapped in GiftWrap for each recipient.
|
||||
*/
|
||||
private suspend fun sendEncryptedFiles(
|
||||
files: List<File>,
|
||||
roomKey: ChatroomKey,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
) {
|
||||
val orchestrator = DesktopUploadOrchestrator()
|
||||
val server = DesktopPreferences.preferredBlossomServer
|
||||
val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() }
|
||||
|
||||
for (file in files) {
|
||||
val cipher = AESGCM()
|
||||
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
|
||||
val url = result.blossom.url ?: continue
|
||||
|
||||
val template =
|
||||
ChatMessageEncryptedFileHeaderEvent.build(
|
||||
to = recipients,
|
||||
url = url,
|
||||
cipher = cipher,
|
||||
mimeType = result.metadata.mimeType,
|
||||
hash = result.encryptedHash,
|
||||
size = result.encryptedSize,
|
||||
dimension =
|
||||
if (result.metadata.width != null && result.metadata.height != null) {
|
||||
DimensionTag(result.metadata.width, result.metadata.height)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
blurhash = result.metadata.blurhash,
|
||||
originalHash = result.metadata.sha256,
|
||||
)
|
||||
account.sendNip17EncryptedFile(template)
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -119,7 +119,6 @@ fun ConversationListPane(
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(280.dp)
|
||||
.fillMaxHeight()
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
|
||||
+150
-53
@@ -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() {
|
||||
|
||||
+6
-9
@@ -55,7 +55,6 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -90,6 +89,7 @@ fun DeckColumnContainer(
|
||||
localCache: DesktopLocalCache,
|
||||
accountManager: AccountManager,
|
||||
account: AccountState.LoggedIn,
|
||||
iAccount: DesktopIAccount,
|
||||
nwcConnection: Nip47URINorm?,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
appScope: CoroutineScope,
|
||||
@@ -129,9 +129,11 @@ fun DeckColumnContainer(
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
compactMode = true,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
@@ -170,9 +172,11 @@ internal fun RootContent(
|
||||
localCache: DesktopLocalCache,
|
||||
accountManager: AccountManager,
|
||||
account: AccountState.LoggedIn,
|
||||
iAccount: DesktopIAccount,
|
||||
nwcConnection: Nip47URINorm?,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
appScope: CoroutineScope,
|
||||
compactMode: Boolean = false,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
@@ -202,19 +206,12 @@ internal fun RootContent(
|
||||
}
|
||||
|
||||
DeckColumnType.Messages -> {
|
||||
val dmSendTracker =
|
||||
remember(relayManager) {
|
||||
DmSendTracker(relayManager.client)
|
||||
}
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope)
|
||||
}
|
||||
DesktopMessagesScreen(
|
||||
account = iAccount,
|
||||
cacheProvider = localCache,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
compactMode = compactMode,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ fun DeckLayout(
|
||||
localCache: DesktopLocalCache,
|
||||
accountManager: AccountManager,
|
||||
account: AccountState.LoggedIn,
|
||||
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
|
||||
nwcConnection: Nip47URINorm?,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
appScope: CoroutineScope,
|
||||
@@ -109,6 +110,7 @@ fun DeckLayout(
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
|
||||
+45
-36
@@ -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,
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.Codec
|
||||
import org.jetbrains.skia.Data
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF
|
||||
private const val MIN_FRAME_DURATION_MS = 20
|
||||
|
||||
private val gifHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun isAnimatedGifUrl(url: String): Boolean {
|
||||
val lower = url.lowercase()
|
||||
return lower.endsWith(".gif") ||
|
||||
lower.contains(".gif?") ||
|
||||
lower.contains(".gif#")
|
||||
}
|
||||
|
||||
private class GifFrames(
|
||||
val frames: List<ImageBitmap>,
|
||||
val durations: List<Int>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AnimatedGifImage(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
var gifFrames by remember(url) { mutableStateOf<GifFrames?>(null) }
|
||||
var currentFrame by remember(url) { mutableIntStateOf(0) }
|
||||
var loadFailed by remember(url) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(url) {
|
||||
currentFrame = 0
|
||||
loadFailed = false
|
||||
gifFrames = withContext(Dispatchers.IO) { decodeGifFrames(url) }
|
||||
if (gifFrames == null) loadFailed = true
|
||||
}
|
||||
|
||||
// Reset frame index when frames change
|
||||
DisposableEffect(url) {
|
||||
onDispose { currentFrame = 0 }
|
||||
}
|
||||
|
||||
val data = gifFrames
|
||||
when {
|
||||
data != null && data.frames.size > 1 -> {
|
||||
LaunchedEffect(data) {
|
||||
while (isActive) {
|
||||
val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS)
|
||||
delay(duration.toLong())
|
||||
currentFrame = (currentFrame + 1) % data.frames.size
|
||||
}
|
||||
}
|
||||
|
||||
Image(
|
||||
bitmap = data.frames[currentFrame],
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
|
||||
data != null -> {
|
||||
Image(
|
||||
bitmap = data.frames[0],
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
|
||||
loadFailed -> {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Box(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeGifFrames(url: String): GifFrames? =
|
||||
try {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = gifHttpClient.newCall(request).execute()
|
||||
val bytes = response.body.bytes()
|
||||
|
||||
val skData = Data.makeFromBytes(bytes)
|
||||
val codec = Codec.makeFromData(skData)
|
||||
val frameCount = codec.frameCount
|
||||
if (frameCount <= 0) return null
|
||||
|
||||
val frameBitmapSize = codec.width.toLong() * codec.height * 4
|
||||
val totalMemory = frameBitmapSize * frameCount
|
||||
val decodableFrames =
|
||||
if (totalMemory > MAX_BITMAP_MEMORY) {
|
||||
// Only decode first frame for huge GIFs
|
||||
1
|
||||
} else {
|
||||
frameCount
|
||||
}
|
||||
|
||||
val frameInfos = codec.framesInfo
|
||||
val frames = ArrayList<ImageBitmap>(decodableFrames)
|
||||
val durations = ArrayList<Int>(decodableFrames)
|
||||
|
||||
for (i in 0 until decodableFrames) {
|
||||
val bitmap = Bitmap()
|
||||
bitmap.allocN32Pixels(codec.width, codec.height)
|
||||
codec.readPixels(bitmap, i)
|
||||
bitmap.setImmutable()
|
||||
frames.add(bitmap.asComposeImageBitmap())
|
||||
durations.add(if (frameInfos.size > i) frameInfos[i].duration else 100)
|
||||
}
|
||||
|
||||
GifFrames(frames, durations)
|
||||
} catch (e: Exception) {
|
||||
println("AnimatedGif: failed to load $url — ${e.message}")
|
||||
null
|
||||
}
|
||||
+111
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import java.awt.Image
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
object ClipboardPasteHandler {
|
||||
fun getClipboardFiles(): List<File> {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
return try {
|
||||
when {
|
||||
clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor) -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(clipboard.getData(DataFlavor.javaFileListFlavor) as? List<File>) ?: emptyList()
|
||||
}
|
||||
|
||||
clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor) -> {
|
||||
val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return emptyList()
|
||||
val buffered = toBufferedImage(image)
|
||||
val tempFile = File.createTempFile("clipboard_", ".png")
|
||||
tempFile.deleteOnExit()
|
||||
ImageIO.write(buffered, "png", tempFile)
|
||||
listOf(tempFile)
|
||||
}
|
||||
|
||||
else -> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toBufferedImage(image: Image): BufferedImage {
|
||||
if (image is BufferedImage) return image
|
||||
val buffered = BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB)
|
||||
buffered.graphics.drawImage(image, 0, 0, null)
|
||||
return buffered
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import java.io.FilenameFilter
|
||||
|
||||
object DesktopFilePicker {
|
||||
private val mediaExtensions =
|
||||
setOf(
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"avif",
|
||||
"mp4",
|
||||
"webm",
|
||||
"mov",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"wav",
|
||||
"flac",
|
||||
)
|
||||
|
||||
fun pickMediaFiles(parent: Frame? = null): List<File> {
|
||||
val dialog =
|
||||
FileDialog(parent, "Select Media", FileDialog.LOAD).apply {
|
||||
isMultipleMode = true
|
||||
filenameFilter =
|
||||
FilenameFilter { _, name ->
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
ext in mediaExtensions
|
||||
}
|
||||
}
|
||||
dialog.isVisible = true
|
||||
return dialog.files?.toList() ?: emptyList()
|
||||
}
|
||||
}
|
||||
+196
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
}
|
||||
+137
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
import androidx.compose.ui.input.key.isMetaPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Desktop
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
val LocalWindowState = compositionLocalOf<androidx.compose.ui.window.WindowState?> { null }
|
||||
|
||||
val LocalAwtWindow = compositionLocalOf<java.awt.Window?> { null }
|
||||
|
||||
val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) }
|
||||
|
||||
enum class ViewMode { DEFAULT, FULLSCREEN }
|
||||
|
||||
private sealed class DownloadState {
|
||||
data object Idle : DownloadState()
|
||||
|
||||
data class Downloading(
|
||||
val progress: Float,
|
||||
val filename: String,
|
||||
) : DownloadState()
|
||||
|
||||
data class Done(
|
||||
val file: File,
|
||||
) : DownloadState()
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : DownloadState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LightboxOverlay(
|
||||
urls: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
initialSeekPosition: Float = 0f,
|
||||
initialFullscreen: Boolean = false,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
|
||||
var viewMode by remember { mutableStateOf(if (initialFullscreen) ViewMode.FULLSCREEN else ViewMode.DEFAULT) }
|
||||
val awtWindow = LocalAwtWindow.current
|
||||
val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
// Sync exclusive fullscreen with viewMode and signal parent layouts
|
||||
LaunchedEffect(viewMode) {
|
||||
isImmersiveFullscreen.value = viewMode == ViewMode.FULLSCREEN
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
awtWindow?.let { FullscreenHelper.enterFullscreen(it) }
|
||||
} else {
|
||||
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Restore fullscreen on dismiss
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
isImmersiveFullscreen.value = false
|
||||
if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-dismiss banner after 3s
|
||||
LaunchedEffect(downloadState) {
|
||||
if (downloadState is DownloadState.Done || downloadState is DownloadState.Failed) {
|
||||
delay(3000)
|
||||
downloadState = DownloadState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
val currentUrl = urls[currentIndex]
|
||||
val isVideo = RichTextParser.isVideoUrl(currentUrl)
|
||||
|
||||
fun triggerSave() {
|
||||
if (downloadState is DownloadState.Downloading) return
|
||||
val url = urls[currentIndex]
|
||||
val filename = url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
|
||||
downloadState = DownloadState.Downloading(progress = -1f, filename = filename)
|
||||
scope.launch {
|
||||
val result =
|
||||
SaveMediaAction.saveMedia(
|
||||
url = url,
|
||||
onProgress = { downloaded, total ->
|
||||
val progress = if (total > 0) downloaded.toFloat() / total else -1f
|
||||
downloadState = DownloadState.Downloading(progress = progress, filename = filename)
|
||||
},
|
||||
)
|
||||
downloadState =
|
||||
if (result != null) {
|
||||
DownloadState.Done(result)
|
||||
} else {
|
||||
DownloadState.Failed("Download failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFullscreen() {
|
||||
viewMode =
|
||||
if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN
|
||||
}
|
||||
|
||||
// Content modifier based on view mode
|
||||
val contentModifier =
|
||||
if (viewMode == ViewMode.DEFAULT) {
|
||||
Modifier.fillMaxSize().padding(48.dp)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(if (viewMode == ViewMode.FULLSCREEN) Color.Black else Color.Black.copy(alpha = 0.9f))
|
||||
.focusRequester(focusRequester)
|
||||
.onKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
when (event.key) {
|
||||
Key.Escape -> {
|
||||
if (viewMode == ViewMode.FULLSCREEN) {
|
||||
viewMode = ViewMode.DEFAULT
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Key.F -> {
|
||||
toggleFullscreen()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionLeft -> {
|
||||
if (currentIndex > 0) currentIndex--
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
if (currentIndex < urls.lastIndex) currentIndex++
|
||||
true
|
||||
}
|
||||
|
||||
Key.S -> {
|
||||
if (event.isCtrlPressed || event.isMetaPressed) {
|
||||
triggerSave()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
if (viewMode != ViewMode.FULLSCREEN) onDismiss()
|
||||
},
|
||||
) {
|
||||
// Main content — video or image
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier =
|
||||
contentModifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
// Consume clicks so backdrop dismiss doesn't fire
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DesktopVideoPlayer(
|
||||
url = currentUrl,
|
||||
autoPlay = true,
|
||||
initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f,
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = { newMode ->
|
||||
viewMode = newMode
|
||||
},
|
||||
modifier =
|
||||
if (viewMode == ViewMode.DEFAULT) {
|
||||
Modifier.widthIn(max = 1200.dp)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
trailingControls = {
|
||||
MoreOptionsMenu(
|
||||
menuExpanded = menuExpanded,
|
||||
onExpandMenu = { menuExpanded = true },
|
||||
onDismissMenu = { menuExpanded = false },
|
||||
onSave = { triggerSave() },
|
||||
onCopyUrl = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
clipboard.setContents(StringSelection(urls[currentIndex]), null)
|
||||
},
|
||||
onOpenInBrowser = {
|
||||
Desktop.getDesktop().browse(URI(urls[currentIndex]))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ZoomableImage(
|
||||
url = currentUrl,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
|
||||
// Download banner (top)
|
||||
AnimatedVisibility(
|
||||
visible = downloadState !is DownloadState.Idle,
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter),
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
) {
|
||||
val state = downloadState
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
when (state) {
|
||||
is DownloadState.Failed -> MaterialTheme.colorScheme.errorContainer
|
||||
is DownloadState.Done -> Color(0xFF2E7D32)
|
||||
else -> Color(0xFF1565C0)
|
||||
},
|
||||
).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (state) {
|
||||
is DownloadState.Downloading -> {
|
||||
Text(
|
||||
state.filename,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (state.progress >= 0f) {
|
||||
LinearProgressIndicator(
|
||||
progress = { state.progress },
|
||||
modifier = Modifier.width(120.dp),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.width(120.dp),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadState.Done -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Saved to ${state.file.name}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
is DownloadState.Failed -> {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
state.message,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
is DownloadState.Idle -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "..." menu (bottom right) — only for images; videos get it in the controls bar
|
||||
// Hidden in fullscreen to keep the view immersive
|
||||
if (!isVideo && viewMode != ViewMode.FULLSCREEN) {
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
) {
|
||||
MoreOptionsMenu(
|
||||
menuExpanded = menuExpanded,
|
||||
onExpandMenu = { menuExpanded = true },
|
||||
onDismissMenu = { menuExpanded = false },
|
||||
onSave = { triggerSave() },
|
||||
onCopyUrl = {
|
||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
||||
clipboard.setContents(StringSelection(urls[currentIndex]), null)
|
||||
},
|
||||
onOpenInBrowser = {
|
||||
Desktop.getDesktop().browse(URI(urls[currentIndex]))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Close button (top-left) — hidden in fullscreen
|
||||
if (viewMode != ViewMode.FULLSCREEN) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Image counter (bottom-center) — hidden in fullscreen
|
||||
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||
Text(
|
||||
text = "${currentIndex + 1} / ${urls.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Navigation arrows — hidden in fullscreen
|
||||
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||
if (currentIndex > 0) {
|
||||
IconButton(
|
||||
onClick = { currentIndex-- },
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Previous",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentIndex < urls.lastIndex) {
|
||||
IconButton(
|
||||
onClick = { currentIndex++ },
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Next",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoreOptionsMenu(
|
||||
menuExpanded: Boolean,
|
||||
onExpandMenu: () -> Unit,
|
||||
onDismissMenu: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCopyUrl: () -> Unit,
|
||||
onOpenInBrowser: () -> Unit,
|
||||
) {
|
||||
Box {
|
||||
IconButton(onClick = onExpandMenu, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "More options",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save") },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismissMenu()
|
||||
onSave()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy URL") },
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismissMenu()
|
||||
onCopyUrl()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open in Browser") },
|
||||
leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismissMenu()
|
||||
onOpenInBrowser()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun MediaAttachmentRow(
|
||||
attachedFiles: List<File>,
|
||||
isUploading: Boolean,
|
||||
onAttach: () -> Unit,
|
||||
onPaste: () -> Unit,
|
||||
onRemove: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onAttach) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach media",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPaste) {
|
||||
Icon(
|
||||
Icons.Default.ContentPaste,
|
||||
contentDescription = "Paste from clipboard",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (file in attachedFiles) {
|
||||
AttachedFileThumbnail(file = file, onRemove = { onRemove(file) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachedFileThumbnail(
|
||||
file: File,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row {
|
||||
AsyncImage(
|
||||
model = file,
|
||||
contentDescription = file.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(20.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = file.name.take(12),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
+270
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
|
||||
/**
|
||||
* Displays a kind 20 picture event (NIP-68) with image-first layout.
|
||||
*/
|
||||
@Composable
|
||||
fun PictureDisplay(
|
||||
event: PictureEvent,
|
||||
modifier: Modifier = Modifier,
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
) {
|
||||
val imetas = remember(event.id) { event.imetaTags() }
|
||||
val imageUrls = remember(imetas) { imetas.mapNotNull { it.url } }
|
||||
val title = remember(event.id) { event.title() }
|
||||
val description = event.content
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
// Images
|
||||
for ((index, url) in imageUrls.withIndex()) {
|
||||
val imageModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
.clip(
|
||||
if (index == 0 && title == null && description.isBlank()) {
|
||||
RoundedCornerShape(8.dp)
|
||||
} else if (index == 0) {
|
||||
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||
} else {
|
||||
RoundedCornerShape(0.dp)
|
||||
},
|
||||
).then(
|
||||
if (onImageClick != null) {
|
||||
Modifier.clickable { onImageClick(imageUrls, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (isAnimatedGifUrl(url)) {
|
||||
AnimatedGifImage(
|
||||
url = url,
|
||||
contentDescription = title,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = title,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title + description below images
|
||||
if (title != null || description.isNotBlank()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (description.isNotBlank()) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -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)
|
||||
}
|
||||
+117
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+332
-63
@@ -22,13 +22,16 @@ package com.vitorpamplona.amethyst.desktop.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -38,16 +41,35 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Urls
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
|
||||
private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
||||
|
||||
/**
|
||||
* Data class for displaying a note card.
|
||||
@@ -69,10 +91,66 @@ data class NoteDisplayData(
|
||||
fun NoteCard(
|
||||
note: NoteDisplayData,
|
||||
modifier: Modifier = Modifier,
|
||||
localCache: DesktopLocalCache? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onAuthorClick: ((String) -> Unit)? = null,
|
||||
onMentionClick: ((String) -> Unit)? = null,
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||
) {
|
||||
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
|
||||
val imageUrls =
|
||||
remember(urls) {
|
||||
urls.withScheme.filter { RichTextParser.isImageUrl(it) }
|
||||
}
|
||||
val videoAndAudioUrls =
|
||||
remember(urls) {
|
||||
urls.withScheme.filter { RichTextParser.isVideoUrl(it) }
|
||||
}
|
||||
val audioUrls =
|
||||
remember(videoAndAudioUrls) {
|
||||
videoAndAudioUrls.filter { url ->
|
||||
val ext =
|
||||
url
|
||||
.substringAfterLast('.', "")
|
||||
.substringBefore('?')
|
||||
.lowercase()
|
||||
ext in AUDIO_EXTENSIONS
|
||||
}
|
||||
}
|
||||
val videoUrls =
|
||||
remember(videoAndAudioUrls, audioUrls) {
|
||||
videoAndAudioUrls - audioUrls.toSet()
|
||||
}
|
||||
val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() }
|
||||
val strippedContent =
|
||||
remember(note.content, mediaUrls) {
|
||||
var text = note.content
|
||||
for (url in mediaUrls) {
|
||||
text = text.replace(url, "").trim()
|
||||
}
|
||||
text
|
||||
}
|
||||
val strippedUrls =
|
||||
remember(urls, mediaUrls) {
|
||||
Urls(
|
||||
withScheme = urls.withScheme - mediaUrls,
|
||||
withoutScheme = urls.withoutScheme,
|
||||
emails = urls.emails,
|
||||
bech32s = urls.bech32s,
|
||||
relayUrls = urls.relayUrls,
|
||||
blossomUris = urls.blossomUris,
|
||||
)
|
||||
}
|
||||
|
||||
// Cap media height to half the window so text is never pushed off-screen
|
||||
val windowState = LocalWindowState.current
|
||||
val maxMediaHeight =
|
||||
if (windowState != null) {
|
||||
(windowState.size.height * 0.5f).coerceAtLeast(200.dp)
|
||||
} else {
|
||||
400.dp
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -80,56 +158,149 @@ fun NoteCard(
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
onClick = onClick ?: {},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Header + text area — clickable to navigate to thread
|
||||
Column(
|
||||
modifier =
|
||||
if (onClick != null) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
// Author with avatar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
if (onAuthorClick != null) {
|
||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = note.pubKeyHex,
|
||||
pictureUrl = note.profilePictureUrl,
|
||||
size = 32.dp,
|
||||
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
|
||||
)
|
||||
// Author with avatar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
if (onAuthorClick != null) {
|
||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = note.pubKeyHex,
|
||||
pictureUrl = note.profilePictureUrl,
|
||||
size = 32.dp,
|
||||
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
text = note.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = note.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (strippedContent.isNotBlank()) {
|
||||
RichTextContent(
|
||||
content = strippedContent,
|
||||
urls = strippedUrls,
|
||||
localCache = localCache,
|
||||
onMentionClick = onMentionClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} // end clickable header+text column
|
||||
|
||||
// Inline images
|
||||
if (imageUrls.isNotEmpty()) {
|
||||
if (strippedContent.isNotBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
for ((index, url) in imageUrls.withIndex()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxMediaHeight)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(
|
||||
if (onImageClick != null) {
|
||||
Modifier.clickable { onImageClick(imageUrls, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
if (isAnimatedGifUrl(url)) {
|
||||
AnimatedGifImage(
|
||||
url = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (url != imageUrls.last()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Inline videos
|
||||
if (videoUrls.isNotEmpty()) {
|
||||
if (strippedContent.isNotBlank() || imageUrls.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
for ((index, url) in videoUrls.withIndex()) {
|
||||
DesktopVideoPlayer(
|
||||
url = url,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxMediaHeight),
|
||||
onFullscreen =
|
||||
if (onMediaClick != null) {
|
||||
{ seekPos -> onMediaClick(videoUrls, index, seekPos) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
if (url != videoUrls.last()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RichTextContent(
|
||||
content = note.content,
|
||||
urls = urls,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// Inline audio
|
||||
if (audioUrls.isNotEmpty()) {
|
||||
if (strippedContent.isNotBlank() || imageUrls.isNotEmpty() || videoUrls.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
for (url in audioUrls) {
|
||||
AudioPlayer(
|
||||
url = url,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (url != audioUrls.last()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -148,52 +319,152 @@ fun NoteCard(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text content with highlighted URLs.
|
||||
* Uses RichTextParser from commons to detect and highlight links.
|
||||
* Resolved bech32 mention with display text and optional pubkey for click navigation.
|
||||
*/
|
||||
private data class ResolvedMention(
|
||||
val displayText: String,
|
||||
val pubKeyHex: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves a nostr: bech32 reference to a display string and optional pubkey.
|
||||
* For npub/nprofile → @displayName + pubkey hex for navigation.
|
||||
* For note/nevent → truncated note ID.
|
||||
*/
|
||||
private fun resolveBech32(
|
||||
bech32: String,
|
||||
localCache: DesktopLocalCache?,
|
||||
): ResolvedMention {
|
||||
val parsed = Nip19Parser.uriToRoute(bech32) ?: return ResolvedMention(bech32)
|
||||
return when (val entity = parsed.entity) {
|
||||
is NPub -> {
|
||||
val user = localCache?.getUserIfExists(entity.hex)
|
||||
ResolvedMention(
|
||||
displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}",
|
||||
pubKeyHex = entity.hex,
|
||||
)
|
||||
}
|
||||
|
||||
is NProfile -> {
|
||||
val user = localCache?.getUserIfExists(entity.hex)
|
||||
ResolvedMention(
|
||||
displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}",
|
||||
pubKeyHex = entity.hex,
|
||||
)
|
||||
}
|
||||
|
||||
is NNote -> {
|
||||
ResolvedMention("note:${entity.hex.take(8)}...")
|
||||
}
|
||||
|
||||
is NEvent -> {
|
||||
ResolvedMention("note:${entity.hex.take(8)}...")
|
||||
}
|
||||
|
||||
else -> {
|
||||
ResolvedMention(bech32.take(24) + "...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts pubkey hex strings from all npub/nprofile bech32 references in a set.
|
||||
* Used to trigger metadata loading for mentioned users.
|
||||
*/
|
||||
fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
|
||||
bech32s.mapNotNull { bech32 ->
|
||||
val parsed = Nip19Parser.uriToRoute(bech32) ?: return@mapNotNull null
|
||||
when (val entity = parsed.entity) {
|
||||
is NPub -> entity.hex
|
||||
is NProfile -> entity.hex
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text content with highlighted URLs and clickable nostr: bech32 mentions.
|
||||
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
|
||||
* and navigate to profile on click.
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextContent(
|
||||
content: String,
|
||||
urls: Urls,
|
||||
localCache: DesktopLocalCache? = null,
|
||||
onMentionClick: ((String) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 10,
|
||||
) {
|
||||
if (urls.withScheme.isEmpty()) {
|
||||
val defaultColor = MaterialTheme.colorScheme.onSurface
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = defaultColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
data class Segment(
|
||||
val start: Int,
|
||||
val raw: String,
|
||||
val isUrl: Boolean,
|
||||
)
|
||||
|
||||
val segments = mutableListOf<Segment>()
|
||||
for (url in urls.withScheme) {
|
||||
val idx = content.indexOf(url)
|
||||
if (idx != -1) segments.add(Segment(idx, url, true))
|
||||
}
|
||||
for (bech32 in urls.bech32s) {
|
||||
val idx = content.indexOf(bech32)
|
||||
if (idx != -1) segments.add(Segment(idx, bech32, false))
|
||||
}
|
||||
segments.sortBy { it.start }
|
||||
|
||||
val annotatedText =
|
||||
buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
// TODO: User the other urls.
|
||||
val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) }
|
||||
|
||||
for (url in sortedUrls) {
|
||||
val startIndex = content.indexOf(url, lastIndex)
|
||||
if (startIndex == -1) continue
|
||||
for (segment in segments) {
|
||||
if (segment.start < lastIndex) continue
|
||||
|
||||
// Add text before URL
|
||||
if (startIndex > lastIndex) {
|
||||
append(content.substring(lastIndex, startIndex))
|
||||
// Add text before segment
|
||||
if (segment.start > lastIndex) {
|
||||
append(content.substring(lastIndex, segment.start))
|
||||
}
|
||||
|
||||
// Add URL with styling
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(url)
|
||||
if (segment.isUrl) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = primaryColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(segment.raw)
|
||||
}
|
||||
} else {
|
||||
val resolved = resolveBech32(segment.raw, localCache)
|
||||
if (resolved.pubKeyHex != null && onMentionClick != null) {
|
||||
val pubKey = resolved.pubKeyHex
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(
|
||||
tag = "mention",
|
||||
styles = TextLinkStyles(SpanStyle(color = primaryColor)),
|
||||
) {
|
||||
onMentionClick(pubKey)
|
||||
},
|
||||
) {
|
||||
append(resolved.displayText)
|
||||
}
|
||||
} else {
|
||||
withStyle(SpanStyle(color = primaryColor)) {
|
||||
append(resolved.displayText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = startIndex + url.length
|
||||
lastIndex = segment.start + segment.raw.length
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
@@ -205,9 +476,7 @@ fun RichTextContent(
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = defaultColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
|
||||
/**
|
||||
* Grid gallery view of a user's picture posts (kind 20).
|
||||
*/
|
||||
@Composable
|
||||
fun GalleryTab(
|
||||
pictureEvents: List<PictureEvent>,
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (pictureEvents.isEmpty()) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"No pictures yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(pictureEvents, key = { it.id }) { event ->
|
||||
val firstImageUrl =
|
||||
remember(event.id) {
|
||||
event.imetaTags().firstNotNullOfOrNull { it.url }
|
||||
}
|
||||
|
||||
if (firstImageUrl != null) {
|
||||
GalleryThumbnail(
|
||||
url = firstImageUrl,
|
||||
onClick = {
|
||||
val allUrls = event.imetaTags().mapNotNull { it.url }
|
||||
onImageClick?.invoke(allUrls, 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryThumbnail(
|
||||
url: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
+35
-64
@@ -40,8 +40,6 @@ import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -61,15 +59,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||
import com.vitorpamplona.amethyst.commons.search.KindRegistry
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
|
||||
@Composable
|
||||
@@ -77,6 +73,7 @@ fun SearchResultsList(
|
||||
state: AdvancedSearchBarState,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
localCache: DesktopLocalCache? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
) {
|
||||
@@ -161,14 +158,24 @@ fun SearchResultsList(
|
||||
if (!collapsed) {
|
||||
val displayNotes = textNotes.take(5)
|
||||
items(displayNotes, key = { "note-${it.id}" }) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
if (textNotes.size > 5) {
|
||||
item(key = "notes-expand") {
|
||||
ExpandableSection(
|
||||
remaining = textNotes.drop(5),
|
||||
) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,14 +202,24 @@ fun SearchResultsList(
|
||||
}
|
||||
if (!collapsed) {
|
||||
items(articles.take(5), key = { "article-${it.id}" }) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
if (articles.size > 5) {
|
||||
item(key = "articles-expand") {
|
||||
ExpandableSection(
|
||||
remaining = articles.drop(5),
|
||||
) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,7 +244,13 @@ fun SearchResultsList(
|
||||
}
|
||||
if (!collapsed) {
|
||||
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,58 +326,6 @@ private fun SortableHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotePreviewCard(
|
||||
event: Event,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Kind badge
|
||||
val kindName = KindRegistry.nameFor(event.kind) ?: "kind ${event.kind}"
|
||||
Text(
|
||||
kindName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
// Author (hex truncated)
|
||||
Text(
|
||||
event.pubKey.take(8) + "..." + event.pubKey.takeLast(4),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
// Timestamp
|
||||
Text(
|
||||
event.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Content preview
|
||||
Text(
|
||||
event.content.take(200),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ExpandableSection(
|
||||
remaining: List<T>,
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MediaServerSettings(
|
||||
initialServers: List<String> = emptyList(),
|
||||
onServersChanged: (List<String>) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val servers = remember { mutableStateListOf<String>().apply { addAll(initialServers) } }
|
||||
val serverStatuses = remember { mutableStateMapOf<String, ServerHealthCheck.ServerStatus>() }
|
||||
var newServerUrl by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
var isChecking by remember { mutableStateOf(false) }
|
||||
|
||||
// Check health on first load (parallel)
|
||||
LaunchedEffect(servers.toList()) {
|
||||
coroutineScope {
|
||||
for (server in servers) {
|
||||
if (server !in serverStatuses) {
|
||||
launch {
|
||||
val status = ServerHealthCheck.check(server)
|
||||
serverStatuses[server] = status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text(
|
||||
"Media Servers (Blossom)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"Configure Blossom servers for media uploads. First server is the default.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Server list
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (server in servers.toList()) {
|
||||
ServerRow(
|
||||
server = server,
|
||||
status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN,
|
||||
isDefault = servers.indexOf(server) == 0,
|
||||
onSetDefault = {
|
||||
servers.remove(server)
|
||||
servers.add(0, server)
|
||||
onServersChanged(servers.toList())
|
||||
},
|
||||
onRemove = {
|
||||
servers.remove(server)
|
||||
serverStatuses.remove(server)
|
||||
onServersChanged(servers.toList())
|
||||
},
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
|
||||
val status = ServerHealthCheck.check(server)
|
||||
serverStatuses[server] = status
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Add server
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newServerUrl,
|
||||
onValueChange = { newServerUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://blossom.example.com") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val url = newServerUrl.trim().removeSuffix("/")
|
||||
if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) {
|
||||
servers.add(url)
|
||||
newServerUrl = ""
|
||||
onServersChanged(servers.toList())
|
||||
scope.launch {
|
||||
val status = ServerHealthCheck.check(url)
|
||||
serverStatuses[url] = status
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Refresh all
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isChecking = true
|
||||
coroutineScope {
|
||||
for (server in servers) {
|
||||
launch {
|
||||
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
|
||||
val status = ServerHealthCheck.check(server)
|
||||
serverStatuses[server] = status
|
||||
}
|
||||
}
|
||||
}
|
||||
isChecking = false
|
||||
}
|
||||
},
|
||||
enabled = !isChecking,
|
||||
) {
|
||||
if (isChecking) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Check All")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidServerUrl(url: String): Boolean {
|
||||
val trimmed = url.trim().removeSuffix("/")
|
||||
return try {
|
||||
val uri = java.net.URI(trimmed)
|
||||
uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".")
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ServerRow(
|
||||
server: String,
|
||||
status: ServerHealthCheck.ServerStatus,
|
||||
isDefault: Boolean,
|
||||
onSetDefault: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Status indicator with tooltip
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
when (status) {
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> "Online"
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable"
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..."
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(12.dp),
|
||||
shape = CircleShape,
|
||||
color =
|
||||
when (status) {
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
server,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (isDefault) {
|
||||
Text(
|
||||
"Default server",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Set as default",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable { onSetDefault() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -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)
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Call
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopBlossomClientTest {
|
||||
private fun mockOkHttp(
|
||||
responseCode: Int,
|
||||
body: String = "",
|
||||
headers: Headers = Headers.headersOf(),
|
||||
): OkHttpClient {
|
||||
val requestSlot = slot<Request>()
|
||||
val mockCall = mockk<Call>()
|
||||
val mockClient = mockk<OkHttpClient>()
|
||||
|
||||
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
|
||||
every { mockCall.execute() } returns
|
||||
Response
|
||||
.Builder()
|
||||
.request(Request.Builder().url("https://example.com").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(responseCode)
|
||||
.message(if (responseCode == 200) "OK" else "Error")
|
||||
.headers(headers)
|
||||
.body(body.toResponseBody())
|
||||
.build()
|
||||
|
||||
return mockClient
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadSuccessReturnsResult() =
|
||||
runTest {
|
||||
val json =
|
||||
"""{"url":"https://blossom.example.com/abc123.png","sha256":"abc123","size":1024}"""
|
||||
val client = DesktopBlossomClient(mockOkHttp(200, json))
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1, 2, 3))
|
||||
|
||||
try {
|
||||
val result =
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = "Nostr abc",
|
||||
)
|
||||
|
||||
assertEquals("https://blossom.example.com/abc123.png", result.url)
|
||||
assertEquals("abc123", result.sha256)
|
||||
assertEquals(1024L, result.size)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadFailureThrowsException() =
|
||||
runTest {
|
||||
val headers = Headers.headersOf("X-Reason", "File too large")
|
||||
val client = DesktopBlossomClient(mockOkHttp(413, "", headers))
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1, 2, 3))
|
||||
|
||||
try {
|
||||
val ex =
|
||||
assertFailsWith<RuntimeException> {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
}
|
||||
assertTrue(ex.message!!.contains("File too large"))
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadFailureUsesStatusCodeWhenNoXReason() =
|
||||
runTest {
|
||||
val client = DesktopBlossomClient(mockOkHttp(500))
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
|
||||
try {
|
||||
val ex =
|
||||
assertFailsWith<RuntimeException> {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
}
|
||||
assertTrue(ex.message!!.contains("500"))
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadSendsAuthorizationHeader() =
|
||||
runTest {
|
||||
val requestSlot = slot<Request>()
|
||||
val mockCall = mockk<Call>()
|
||||
val mockClient = mockk<OkHttpClient>()
|
||||
|
||||
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
|
||||
every { mockCall.execute() } returns
|
||||
Response
|
||||
.Builder()
|
||||
.request(Request.Builder().url("https://example.com").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
|
||||
.build()
|
||||
|
||||
val client = DesktopBlossomClient(mockClient)
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
|
||||
try {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = "Nostr base64token",
|
||||
)
|
||||
|
||||
val sentRequest = requestSlot.captured
|
||||
assertEquals("Nostr base64token", sentRequest.header("Authorization"))
|
||||
assertEquals("https://blossom.example.com/upload", sentRequest.url.toString())
|
||||
assertEquals("PUT", sentRequest.method)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadUrlStripsTrailingSlash() =
|
||||
runTest {
|
||||
val requestSlot = slot<Request>()
|
||||
val mockCall = mockk<Call>()
|
||||
val mockClient = mockk<OkHttpClient>()
|
||||
|
||||
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
|
||||
every { mockCall.execute() } returns
|
||||
Response
|
||||
.Builder()
|
||||
.request(Request.Builder().url("https://example.com").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
|
||||
.build()
|
||||
|
||||
val client = DesktopBlossomClient(mockClient)
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
|
||||
try {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com/",
|
||||
authHeader = null,
|
||||
)
|
||||
|
||||
// Should not have double slash
|
||||
assertEquals("https://blossom.example.com/upload", requestSlot.captured.url.toString())
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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
|
||||
}
|
||||
}
|
||||
+172
@@ -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
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopUploadOrchestratorTest {
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockkObject(DesktopBlossomAuth)
|
||||
coEvery {
|
||||
DesktopBlossomAuth.createUploadAuth(any(), any(), any(), any())
|
||||
} returns "Nostr fakeAuthToken"
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
unmockkObject(DesktopBlossomAuth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadCallsClientWithCorrectParameters() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
val fileSlot = slot<File>()
|
||||
val contentTypeSlot = slot<String>()
|
||||
val urlSlot = slot<String>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(
|
||||
file = capture(fileSlot),
|
||||
contentType = capture(contentTypeSlot),
|
||||
serverBaseUrl = capture(urlSlot),
|
||||
authHeader = any(),
|
||||
)
|
||||
} returns
|
||||
BlossomUploadResult(
|
||||
url = "https://blossom.example.com/abc123.png",
|
||||
sha256 = "abc123",
|
||||
size = 100,
|
||||
)
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
val img = java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB)
|
||||
javax.imageio.ImageIO.write(img, "png", file)
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = "test upload",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
mockClient.upload(
|
||||
file = any(),
|
||||
contentType = any(),
|
||||
serverBaseUrl = any(),
|
||||
authHeader = any(),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("https://blossom.example.com", urlSlot.captured)
|
||||
assertEquals("image/png", contentTypeSlot.captured)
|
||||
assertNotNull(result.metadata)
|
||||
assertEquals("image/png", result.metadata.mimeType)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadPassesSameFileWhenNoStripExif() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
val fileSlot = slot<File>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(
|
||||
file = capture(fileSlot),
|
||||
contentType = any(),
|
||||
serverBaseUrl = any(),
|
||||
authHeader = any(),
|
||||
)
|
||||
} returns BlossomUploadResult(url = "https://example.com/hash")
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("content")
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = "https://example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
assertEquals(file.absolutePath, fileSlot.captured.absolutePath)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadComputesMetadata() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(any<java.io.File>(), any<String>(), any<String>(), any())
|
||||
} returns BlossomUploadResult(url = "https://example.com/hash")
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("hello world")
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = "https://example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
assertEquals(11L, result.metadata.size)
|
||||
assertTrue(result.metadata.sha256.length == 64)
|
||||
assertEquals("application/octet-stream", result.metadata.mimeType)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadPassesAuthHeaderToClient() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
val authSlot = slot<String?>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(
|
||||
file = any(),
|
||||
contentType = any(),
|
||||
serverBaseUrl = any(),
|
||||
authHeader = captureNullable(authSlot),
|
||||
)
|
||||
} returns BlossomUploadResult(url = "https://example.com/hash")
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("data")
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = "https://example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
assertEquals("Nostr fakeAuthToken", authSlot.captured)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# TODO
|
||||
|
||||
## DM: Mixed NIP-04 + NIP-17 conversations
|
||||
|
||||
**Question:** What happens when a conversation with an npub has both NIP-04 (kind 4) and NIP-17 (kind 14/15 in GiftWrap) messages?
|
||||
|
||||
**Current behavior to investigate:**
|
||||
- NIP-04 messages use `PrivateDmEvent.chatroomKey(pubKey)` → creates a `ChatroomKey` based on recipient
|
||||
- NIP-17 messages use `ChatMessageEvent.chatroomKey(pubKey)` → also creates a `ChatroomKey` based on group members
|
||||
- Do these produce the **same** `ChatroomKey` for 1-on-1 chats? If yes, both message types merge into one conversation. If no, they appear as separate conversations.
|
||||
- Android Amethyst handles this — check how `Account.kt` merges them
|
||||
- Edge cases: timeline ordering, decryption display, NIP-04 messages showing as "legacy" vs NIP-17
|
||||
|
||||
**Action:** Test with a real conversation that has both types. If they split into two rooms, we need to merge them in `ChatroomListState` or unify the `ChatroomKey` derivation.
|
||||
@@ -0,0 +1,604 @@
|
||||
# Blossom Protocol Research
|
||||
|
||||
**Date**: 2026-03-16
|
||||
**Sources**: hzrd149/blossom GitHub (BUD specs), NIP-B7, Nostrify docs, Amethyst upstream codebase, Primal blog posts
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Blossom (**Bl**obs **O**n **S**imple **S**erver**om**... or something) is a specification for HTTP endpoints that let users store binary blobs on publicly accessible servers. Blobs are content-addressed by their **SHA-256 hash**. Uses Nostr keypairs for identity and authorization.
|
||||
|
||||
**Two Nostr event kinds:**
|
||||
- **Kind 24242** -- Authorization token (BUD-11)
|
||||
- **Kind 10063** -- User's Blossom server list (BUD-03, NIP-B7)
|
||||
|
||||
**BUD index (BUD-00 through BUD-11):**
|
||||
|
||||
| BUD | Name | Status | Required |
|
||||
|-----|------|--------|----------|
|
||||
| 00 | BUD framework | - | - |
|
||||
| 01 | Server requirements + blob retrieval | draft | mandatory |
|
||||
| 02 | Upload + management | draft | optional |
|
||||
| 03 | User server list (kind 10063) | draft | optional |
|
||||
| 04 | Mirroring | draft | optional |
|
||||
| 05 | Media optimization | draft | optional |
|
||||
| 06 | Upload requirements (HEAD preflight) | draft | optional |
|
||||
| 07 | Payment required (402) | draft | optional |
|
||||
| 08 | NIP-94 file metadata tags | draft | optional |
|
||||
| 09 | Blob report | draft | optional |
|
||||
| 10 | Blossom URI scheme | draft | optional |
|
||||
| 11 | Nostr authorization | draft | optional |
|
||||
|
||||
---
|
||||
|
||||
## BUD-01: Server Requirements + Blob Retrieval
|
||||
|
||||
**Status:** `draft` `mandatory`
|
||||
|
||||
### CORS
|
||||
|
||||
All responses MUST set `Access-Control-Allow-Origin: *`.
|
||||
|
||||
Preflight (`OPTIONS`) responses MUST also set:
|
||||
```
|
||||
Access-Control-Allow-Headers: Authorization, *
|
||||
Access-Control-Allow-Methods: GET, HEAD, PUT, DELETE
|
||||
```
|
||||
|
||||
MAY set `Access-Control-Max-Age: 86400` (cache 24h).
|
||||
|
||||
### Error Responses
|
||||
|
||||
Any 4xx/5xx response MAY include `X-Reason` header with human-readable error message.
|
||||
|
||||
### Endpoints
|
||||
|
||||
All endpoints served from domain root. No path prefix.
|
||||
|
||||
#### GET /<sha256> -- Get Blob
|
||||
|
||||
```http
|
||||
GET /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
|
||||
Host: cdn.example.com
|
||||
```
|
||||
|
||||
Response:
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/pdf
|
||||
Content-Length: 184292
|
||||
|
||||
<binary blob data>
|
||||
```
|
||||
|
||||
- MUST accept optional file extension in URL (`.pdf`, `.png`, etc.)
|
||||
- MUST return correct `Content-Type` regardless of extension
|
||||
- MUST default to `application/octet-stream` if MIME unknown
|
||||
- MAY require authorization (BUD-11)
|
||||
|
||||
**Proxying/Redirection:**
|
||||
- 3xx redirects MUST redirect to URL containing same SHA-256 hash
|
||||
- Destination MUST set `Access-Control-Allow-Origin: *`, `Content-Type`, `Content-Length`
|
||||
|
||||
**Range Requests:**
|
||||
- Servers SHOULD support `Range` header (RFC 7233) on GET
|
||||
- Signal via `Accept-Ranges: bytes` and `Content-Length` on HEAD
|
||||
|
||||
#### HEAD /<sha256> -- Has Blob
|
||||
|
||||
Identical to GET but MUST NOT return body. MUST return same `Content-Type` and `Content-Length` headers.
|
||||
|
||||
---
|
||||
|
||||
## BUD-02: Upload + Management
|
||||
|
||||
**Status:** `draft` `optional`
|
||||
|
||||
### Blob Descriptor
|
||||
|
||||
The standard JSON response for all upload/mirror operations:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf",
|
||||
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
|
||||
"size": 184292,
|
||||
"type": "application/pdf",
|
||||
"uploaded": 1725105921
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `url` -- Public URL to `GET /<sha256>` endpoint **with file extension**
|
||||
- `sha256` -- Hex-encoded SHA-256 of the blob
|
||||
- `size` -- Size in bytes
|
||||
- `type` -- MIME type (fallback `application/octet-stream`)
|
||||
- `uploaded` -- Unix timestamp
|
||||
|
||||
MAY include: `magnet`, `infohash`, `ipfs`
|
||||
|
||||
### PUT /upload -- Upload Blob
|
||||
|
||||
```http
|
||||
PUT /upload HTTP/1.1
|
||||
Host: cdn.example.com
|
||||
Authorization: Nostr <base64url-encoded kind 24242 event>
|
||||
Content-Type: image/png
|
||||
Content-Length: 184292
|
||||
X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553
|
||||
|
||||
<raw binary data>
|
||||
```
|
||||
|
||||
Response (success):
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://cdn.example.com/b167...553.png",
|
||||
"sha256": "b167...553",
|
||||
"size": 184292,
|
||||
"type": "image/png",
|
||||
"uploaded": 1725105921
|
||||
}
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Server MUST NOT modify the blob
|
||||
- Server MUST compute SHA-256 over exact bytes received
|
||||
- Client SHOULD include `Content-Type` and `Content-Length`
|
||||
- Client MAY provide `X-SHA-256` header (hex lowercase)
|
||||
- Server MAY use `X-SHA-256` for pre-upload rejection policies
|
||||
- Success: 2xx with Blob Descriptor
|
||||
- Failure: 4xx with error message
|
||||
|
||||
### GET /list/<pubkey> -- List Blobs (Unrecommended)
|
||||
|
||||
Optional. Returns JSON array of Blob Descriptors for a pubkey.
|
||||
|
||||
Query params:
|
||||
- `cursor` -- SHA-256 of last blob (cursor-based pagination)
|
||||
- `limit` -- Max results
|
||||
- `since`/`until` -- Filter by upload date (deprecated for pagination)
|
||||
|
||||
Sorted by `uploaded` descending.
|
||||
|
||||
### DELETE /<sha256> -- Delete Blob
|
||||
|
||||
```http
|
||||
DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
|
||||
Host: cdn.example.com
|
||||
Authorization: Nostr <base64url-encoded kind 24242 event>
|
||||
```
|
||||
|
||||
- Multiple `x` tags in auth token MUST NOT be interpreted as batch delete
|
||||
|
||||
---
|
||||
|
||||
## BUD-03: User Server List
|
||||
|
||||
**Kind 10063** (replaceable event).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10063,
|
||||
"tags": [
|
||||
["server", "https://cdn.self.hosted"],
|
||||
["server", "https://cdn.satellite.earth"],
|
||||
["alt", "File servers used by the author"]
|
||||
],
|
||||
"content": ""
|
||||
}
|
||||
```
|
||||
|
||||
- Tag order = priority. Most trusted/reliable first.
|
||||
- Clients MUST upload to at least the first server in user's list.
|
||||
- Clients MAY mirror to other listed servers via BUD-04.
|
||||
|
||||
**Discovery flow when URL breaks:**
|
||||
1. Extract 64-char hex hash from broken URL
|
||||
2. Fetch author's kind:10063 event
|
||||
3. Try each listed server in order
|
||||
4. Fall back to well-known servers
|
||||
|
||||
---
|
||||
|
||||
## BUD-04: Mirroring
|
||||
|
||||
**Status:** `draft` `optional`
|
||||
|
||||
### PUT /mirror -- Mirror Blob
|
||||
|
||||
```http
|
||||
PUT /mirror HTTP/1.1
|
||||
Host: backup-server.example.com
|
||||
Authorization: Nostr <base64url-encoded kind 24242 event>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://cdn.satellite.earth/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
Response: Blob Descriptor (same as upload).
|
||||
|
||||
Key rules:
|
||||
- Server downloads blob from provided URL
|
||||
- Server SHOULD use `Content-Type` from origin server
|
||||
- Server verifies downloaded blob hash matches `x` tag in auth token
|
||||
- Returns 2xx + Blob Descriptor on success, 4xx on failure
|
||||
|
||||
**Typical flow:**
|
||||
1. Client uploads to Server A, gets Blob Descriptor with URL
|
||||
2. Client sends URL to Server B's `/mirror` with same upload auth token
|
||||
3. Server B downloads from Server A
|
||||
4. Server B verifies hash matches `x` tag
|
||||
5. Server B returns Blob Descriptor
|
||||
|
||||
---
|
||||
|
||||
## BUD-05: Media Optimization
|
||||
|
||||
**Status:** `draft` `optional`
|
||||
|
||||
### PUT /media -- Optimized Upload
|
||||
|
||||
```http
|
||||
PUT /media HTTP/1.1
|
||||
Host: trusted-server.example.com
|
||||
Authorization: Nostr <base64url-encoded kind 24242 event with t=media>
|
||||
Content-Type: image/png
|
||||
Content-Length: 4194304
|
||||
|
||||
<raw binary data>
|
||||
```
|
||||
|
||||
Response: Blob Descriptor -- but hash will differ from input because server transforms the file.
|
||||
|
||||
Key differences from `/upload`:
|
||||
- Server MAY modify/optimize the blob (strip EXIF, compress, transcode)
|
||||
- The returned SHA-256 will be of the **optimized** blob, not the original
|
||||
- Client has NO control over optimization process
|
||||
- `t` tag in auth event must be `media` (not `upload`)
|
||||
|
||||
### HEAD /media
|
||||
|
||||
Same as HEAD /upload (BUD-06) but for the media endpoint.
|
||||
|
||||
### Client Implementation Pattern
|
||||
|
||||
1. User selects a "trusted processing" server
|
||||
2. Client uploads original media to `/media` on trusted server
|
||||
3. Gets back optimized blob descriptor (new hash)
|
||||
4. Client signs new upload auth for the optimized hash
|
||||
5. Calls `/mirror` on other servers to distribute the optimized blob
|
||||
|
||||
This is what Primal does -- all Primal 2.2+ apps use `/media` by default, strips metadata, then mirrors.
|
||||
|
||||
---
|
||||
|
||||
## BUD-06: Upload Requirements (HEAD Preflight)
|
||||
|
||||
**Status:** `draft` `optional`
|
||||
|
||||
### HEAD /upload -- Pre-flight Check
|
||||
|
||||
Client sends blob metadata, server says yes/no before actual upload.
|
||||
|
||||
Request:
|
||||
```http
|
||||
HEAD /upload HTTP/1.1
|
||||
Host: cdn.example.com
|
||||
X-Content-Type: application/pdf
|
||||
X-Content-Length: 184292
|
||||
X-SHA-256: 88a74d0b866c8ba79251a11fe5ac807839226870e77355f02eaf68b156522576
|
||||
Authorization: Nostr <base64url-encoded event>
|
||||
```
|
||||
|
||||
Success:
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
```
|
||||
|
||||
Failure examples:
|
||||
```http
|
||||
HTTP/1.1 400 Bad Request
|
||||
X-Reason: Invalid X-SHA-256 header format. Expected a string.
|
||||
|
||||
HTTP/1.1 401 Unauthorized
|
||||
X-Reason: Authorization required for uploading video files.
|
||||
|
||||
HTTP/1.1 403 Forbidden
|
||||
X-Reason: SHA-256 hash banned.
|
||||
|
||||
HTTP/1.1 411 Length Required
|
||||
X-Reason: Missing X-Content-Length header.
|
||||
|
||||
HTTP/1.1 413 Content Too Large
|
||||
X-Reason: File too large. Max allowed size is 100MB.
|
||||
|
||||
HTTP/1.1 415 Unsupported Media Type
|
||||
X-Reason: Unsupported file type.
|
||||
```
|
||||
|
||||
**Note:** Uses `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers (not standard `Content-*`).
|
||||
|
||||
---
|
||||
|
||||
## BUD-07: Payment Required
|
||||
|
||||
Servers MAY return `402 Payment Required` with payment method headers:
|
||||
|
||||
```http
|
||||
HTTP/1.1 402 Payment Required
|
||||
X-Cashu: "<NUT-24 cashu token>"
|
||||
X-Lightning: "<BOLT-11 invoice>"
|
||||
```
|
||||
|
||||
After payment, client retries with proof:
|
||||
- Cashu: serialized `cashuB` token per NUT-24
|
||||
- Lightning: preimage of the BOLT-11 payment
|
||||
|
||||
HEAD requests inform about cost but should not be retried with payment; proceed to PUT/GET after paying.
|
||||
|
||||
---
|
||||
|
||||
## BUD-08: NIP-94 File Metadata Tags
|
||||
|
||||
Servers MAY include a `nip94` field in Blob Descriptor responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://cdn.example.com/b167...553.pdf",
|
||||
"sha256": "b167...553",
|
||||
"size": 184292,
|
||||
"type": "application/pdf",
|
||||
"uploaded": 1725105921,
|
||||
"nip94": [
|
||||
["url", "https://cdn.example.com/b167...553.pdf"],
|
||||
["m", "application/pdf"],
|
||||
["x", "b167...553"],
|
||||
["size", "184292"],
|
||||
["magnet", "magnet:?xt=urn:btih:..."],
|
||||
["i", "infohash-here"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Follows NIP-94 tag format as KV pairs. Allows clients to get standardized metadata without separate requests.
|
||||
|
||||
---
|
||||
|
||||
## BUD-09: Blob Report
|
||||
|
||||
### PUT /report
|
||||
|
||||
Body: signed NIP-56 report event (kind 1984):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1984,
|
||||
"content": "This blob contains illegal content",
|
||||
"tags": [
|
||||
["x", "<sha256>", "illegal"],
|
||||
["p", "<uploader-pubkey>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal.
|
||||
|
||||
---
|
||||
|
||||
## BUD-10: Blossom URI Scheme
|
||||
|
||||
Format:
|
||||
```
|
||||
blossom:<sha256>.<ext>[?param1=value1¶m2=value2...]
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=<author-hex-pubkey>&sz=184292
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
- `xs` -- Server domain hints (tried first). Repeatable.
|
||||
- `as` -- Author hex pubkey for BUD-03 server list lookup. Repeatable.
|
||||
- `sz` -- Size in bytes.
|
||||
|
||||
**Resolution priority:**
|
||||
1. Direct server hints (`xs`) via `GET /<sha256>`
|
||||
2. Author server lists (fetch kind:10063 for each `as` pubkey)
|
||||
3. Fallback to well-known servers or local cache
|
||||
|
||||
---
|
||||
|
||||
## BUD-11: Authorization
|
||||
|
||||
### Kind 24242 Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<event-id>",
|
||||
"pubkey": "<hex-pubkey>",
|
||||
"created_at": 1725105921,
|
||||
"kind": 24242,
|
||||
"tags": [
|
||||
["t", "upload"],
|
||||
["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"],
|
||||
["expiration", "1725109521"],
|
||||
["server", "cdn.example.com"]
|
||||
],
|
||||
"content": "Upload cat photo",
|
||||
"sig": "<schnorr-signature>"
|
||||
}
|
||||
```
|
||||
|
||||
### Required Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `t` | Action verb: `get`, `upload`, `list`, `delete`, `media` |
|
||||
| `expiration` | Unix timestamp when token expires (NIP-40) |
|
||||
|
||||
### Optional Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `x` | SHA-256 hash of specific blob. Multiple allowed. |
|
||||
| `server` | Domain restriction (lowercase). Multiple allowed. |
|
||||
| `size` | File size in bytes (Amethyst adds this) |
|
||||
|
||||
### Authorization Header
|
||||
|
||||
```
|
||||
Authorization: Nostr <base64url-encoded JSON of the kind 24242 event>
|
||||
```
|
||||
|
||||
Note: Amethyst uses standard Base64 (not Base64url), which works in practice.
|
||||
|
||||
### Endpoint Authorization Requirements
|
||||
|
||||
| Endpoint | `t` tag | Hash source | `x` tag |
|
||||
|----------|---------|-------------|---------|
|
||||
| GET/HEAD /<sha256> | `get` | URL path | optional |
|
||||
| PUT /upload | `upload` | X-SHA-256 header | required |
|
||||
| HEAD /upload | `upload` | X-SHA-256 header | required |
|
||||
| DELETE /<sha256> | `delete` | URL path | required |
|
||||
| GET /list/<pubkey> | `list` | -- | N/A |
|
||||
| PUT /mirror | `upload` | mirrored blob hash | required |
|
||||
| PUT /media | `media` | X-SHA-256 header | required |
|
||||
| HEAD /media | `media` | X-SHA-256 header | required |
|
||||
|
||||
### Validation Checklist (Server)
|
||||
|
||||
1. Event kind == 24242
|
||||
2. `created_at` < now
|
||||
3. `expiration` > now
|
||||
4. `t` tag matches endpoint action
|
||||
5. `server` tags (if present) include this server's domain
|
||||
6. `x` tags (if required) match the blob hash
|
||||
|
||||
### Security Note
|
||||
|
||||
Unscoped tokens (no `server` tag) can be replayed to other servers. Always scope `delete` tokens.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling -- HTTP Status Codes
|
||||
|
||||
| Code | Meaning | When |
|
||||
|------|---------|------|
|
||||
| 200 | Success | GET, HEAD, successful upload/mirror/delete |
|
||||
| 3xx | Redirect | GET with CDN redirect (must preserve hash in URL) |
|
||||
| 400 | Bad Request | Invalid headers, malformed auth |
|
||||
| 401 | Unauthorized | Missing or invalid authorization |
|
||||
| 402 | Payment Required | BUD-07 paid servers |
|
||||
| 403 | Forbidden | Hash banned, user blocked |
|
||||
| 404 | Not Found | Blob doesn't exist |
|
||||
| 411 | Length Required | Missing Content-Length / X-Content-Length |
|
||||
| 413 | Content Too Large | File exceeds server limit |
|
||||
| 415 | Unsupported Media Type | Server doesn't accept this MIME type |
|
||||
|
||||
All error responses MAY include `X-Reason` header.
|
||||
|
||||
---
|
||||
|
||||
## Popular Blossom Servers
|
||||
|
||||
| Server | Limits | Notes |
|
||||
|--------|--------|-------|
|
||||
| `blossom.nostr.build` | 100 MiB hard, 20 MiB free | Run by nostr.build team. Supports BUD-01,02,04,05,06,08 |
|
||||
| `blossom.band` | 100 MiB hard, 20 MiB free | Community server |
|
||||
| `blossom.primal.net` | Integrated with Primal stack | Uses /media by default, strips metadata |
|
||||
| `cdn.satellite.earth` | Unknown | Satellite CDN |
|
||||
| `blossom.azzamo.net` | Free tier + premium | Azzamo's server |
|
||||
| `blosstr.com` | Enterprise-grade | Commercial offering |
|
||||
|
||||
Rate limiting is not standardized in the protocol. Each server implements its own policies. Free tiers generally have stricter limits. BUD-06 HEAD preflight is the mechanism for discovering server limitations before uploading.
|
||||
|
||||
---
|
||||
|
||||
## Client Implementations
|
||||
|
||||
### Amethyst (Kotlin -- upstream)
|
||||
|
||||
**Quartz library** (`quartz/src/commonMain/kotlin/.../nipB7Blossom/`):
|
||||
- `BlossomAuthorizationEvent` -- Kind 24242 event creation (get, upload, delete, list)
|
||||
- `BlossomServersEvent` -- Kind 10063 server list management
|
||||
- `BlossomUploadResult` -- Blob Descriptor deserialization (kotlinx.serialization)
|
||||
- `BlossomUri` -- BUD-10 URI parsing/serialization
|
||||
|
||||
**Android app** (`amethyst/src/main/java/.../service/uploads/blossom/`):
|
||||
- `BlossomUploader` -- PUT /upload + DELETE implementation using OkHttp
|
||||
- `BlossomServerResolver` -- BUD-10 URI resolution with LruCache
|
||||
- `ServerHeadCache` -- HEAD request caching for blob existence checks
|
||||
- `UploadOrchestrator` -- Orchestrates NIP-95, NIP-96, and Blossom uploads
|
||||
|
||||
**Upload flow:**
|
||||
1. Read file, compute SHA-256 hash + size
|
||||
2. Compute blurhash metadata locally
|
||||
3. Create kind 24242 auth event (t=upload, x=hash, expiration=+1hr)
|
||||
4. Base64-encode auth event JSON
|
||||
5. PUT /upload with `Authorization: Nostr <base64>`, `Content-Type`, `Content-Length`
|
||||
6. Parse Blob Descriptor response
|
||||
7. Download + verify the uploaded file (re-hash check)
|
||||
|
||||
**Key:** Amethyst does NOT use `/media` endpoint. Uses `/upload` only. No mirroring implemented.
|
||||
|
||||
### Primal
|
||||
|
||||
- All Primal 2.2+ apps use `/media` (BUD-05) by default
|
||||
- Strips all metadata before saving
|
||||
- Optionally mirrors to other Blossom servers per user settings
|
||||
- Deeply integrated into Primal stack, enabled by default
|
||||
|
||||
### Nostrify (TypeScript/Web)
|
||||
|
||||
```typescript
|
||||
const uploader = new BlossomUploader({
|
||||
servers: ['https://blossom.primal.net/'],
|
||||
signer: window.nostr,
|
||||
expiresIn: 60, // seconds
|
||||
});
|
||||
|
||||
const tags = await uploader.upload(file);
|
||||
// Returns NIP-94 tags: url, x, ox, size, m
|
||||
```
|
||||
|
||||
- `ox` tag = original hash (before server processing)
|
||||
- `x` tag = final hash (after optimization if /media used)
|
||||
|
||||
### NDK Blossom (@nostr-dev-kit/ndk-blossom)
|
||||
|
||||
npm package wrapping BUD-01 through BUD-06. TypeScript.
|
||||
|
||||
### Dart NDK (dart-nostr.com)
|
||||
|
||||
Has Blossom use case documentation. Flutter/Dart integration.
|
||||
|
||||
---
|
||||
|
||||
## Key Protocol Design Decisions
|
||||
|
||||
1. **Content addressing via SHA-256** -- Same file = same hash everywhere. Deduplication is free.
|
||||
2. **Servers are interchangeable** -- Any server with the blob can serve it. URLs break? Find the hash elsewhere.
|
||||
3. **No server-side processing on /upload** -- Bit-perfect storage. Hash computed over exact bytes received.
|
||||
4. **/media is the exception** -- Trusted server processes/optimizes. New hash for result.
|
||||
5. **Authorization is opt-in per endpoint** -- Servers choose what to protect.
|
||||
6. **User controls server list** -- Kind 10063 event = user's preferred servers.
|
||||
7. **Mirror for redundancy** -- Upload once, mirror to N servers.
|
||||
|
||||
---
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
- Does BUD-11 require base64url (no padding) or standard base64? Spec says base64url, Amethyst uses standard base64 -- servers seem to accept both.
|
||||
- What's the recommended expiration window for auth tokens? Amethyst uses 1 hour.
|
||||
- How do clients handle the `/media` flow when the optimized hash differs from original? Need to re-sign auth for mirror requests with the new hash.
|
||||
- Is there a standard way to discover server capabilities (which BUDs supported)? Not currently -- no capability endpoint defined.
|
||||
- How to handle upload failures mid-stream for large files? No chunked upload in spec.
|
||||
- Server-side dedup behavior when same hash uploaded by different users? Implementation-specific.
|
||||
@@ -0,0 +1,468 @@
|
||||
# Brainstorm: Desktop Media — Full Parity
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Status:** Draft
|
||||
**Branch:** TBD (`feat/desktop-media`)
|
||||
|
||||
## What We're Building
|
||||
|
||||
Full media functionality for Amethyst Desktop — display, upload, gallery, lightbox, video playback, encrypted media, and desktop-native UX. Feature parity with Android Amethyst's media stack, adapted for mouse-first desktop interaction.
|
||||
|
||||
### Scope
|
||||
|
||||
| Feature | Included | Notes |
|
||||
|---------|----------|-------|
|
||||
| Image display in notes | Yes | Coil3 AsyncImage, blurhash previews |
|
||||
| Video playback | Yes | VLCJ with bundled libvlc |
|
||||
| Blossom upload | Yes | Extract to commons, Blossom-only (no NIP-96) |
|
||||
| Drag-drop / clipboard paste | Yes | Essential desktop UX from day 1 |
|
||||
| Lightbox / zoom | Yes | Full-screen media viewer with zoom |
|
||||
| Image gallery carousel | Yes | Multi-image posts |
|
||||
| Profile gallery (NIP-68) | Yes | Kind 20 picture posts, create + view |
|
||||
| Video events (NIP-71) | Yes | Kind 21/22 display |
|
||||
| Encrypted media (NIP-17 DMs) | Yes | Full send + receive |
|
||||
| Media server management | Yes | Blossom server list (kind 10063) |
|
||||
| Audio/voice playback | Yes | MP3, OGG, FLAC, WAV |
|
||||
| Media compression | Yes | Desktop-adapted (Java ImageIO) |
|
||||
| Blurhash generation on upload | Yes | Already in commons/ |
|
||||
| EXIF stripping | Yes | Privacy: strip metadata before upload |
|
||||
| Alt text / accessibility | Yes | NIP-92 imeta alt field |
|
||||
|
||||
### Out of Scope (for now)
|
||||
|
||||
- NIP-96 upload (deprecated, Blossom replaces it)
|
||||
- Voice recording (microphone capture — desktop-specific, complex)
|
||||
- Picture-in-picture video
|
||||
- Live streaming (NIP-53)
|
||||
- Torrent/magnet distribution
|
||||
|
||||
## Why This Approach
|
||||
|
||||
### Blossom Only (No NIP-96)
|
||||
|
||||
NIP-96 is officially marked "unrecommended: replaced by blossom APIs" in the NIP registry. Building desktop from scratch gives us the opportunity to skip legacy protocol support entirely.
|
||||
|
||||
**Blossom advantages:**
|
||||
- Content-addressed (SHA-256) — files portable across servers
|
||||
- Native mirroring (BUD-04) — upload once, mirror to N servers
|
||||
- Server-side optimization (BUD-05) — `/media` endpoint
|
||||
- Payment support (BUD-07) — Cashu/Lightning
|
||||
- Clean URI scheme (BUD-10) — `blossom:<hash>.<ext>`
|
||||
|
||||
### Extraction Strategy — Layered Architecture
|
||||
|
||||
Android's BlossomUploader is tightly coupled to Android (`Context`, `Uri`, `ContentResolver`). We need to separate the HTTP upload protocol from platform file access.
|
||||
|
||||
**Layer 1: commons/commonMain — Pure Blossom protocol client**
|
||||
- `BlossomClient` — HTTP PUT /upload, /mirror, /media, DELETE, GET /list. Takes `InputStream` + metadata. Returns `BlobDescriptor`. No platform deps.
|
||||
- `BlossomAuthHelper` — Creates kind 24242 auth events, base64-encodes for Authorization header
|
||||
- `BlossomServerDiscovery` — Queries kind 10063, resolves server list, caches
|
||||
- `MediaUploadResult` — Result data class (already platform-agnostic)
|
||||
- `UploadOrchestrator` — Coordinates upload to server + optional optimization + mirroring
|
||||
- `MultiUploadOrchestrator` — Manages parallel upload of multiple files
|
||||
- `MediaUploadTracker` — StateFlow-based progress tracking
|
||||
- `ServerHeadCache` — BUD-06 pre-flight response cache
|
||||
|
||||
**Layer 2: commons/commonMain — expect/actual for platform file operations**
|
||||
```
|
||||
expect fun readFileBytes(path: String): ByteArray
|
||||
expect fun computeFileSha256(path: String): String
|
||||
expect fun getMimeType(path: String): String?
|
||||
expect fun getFileSize(path: String): Long
|
||||
expect class MediaMetadataExtractor {
|
||||
fun extractDimensions(path: String): Pair<Int, Int>?
|
||||
fun extractBlurhash(path: String): String?
|
||||
}
|
||||
expect class MediaCompressor {
|
||||
fun compressImage(path: String, quality: Float): String
|
||||
fun stripExif(path: String): String
|
||||
}
|
||||
```
|
||||
|
||||
**Layer 3: Platform actuals**
|
||||
- `androidMain/` — Uses `ContentResolver`, `Uri`, Android Bitmap, `MediaMetadataRetriever`
|
||||
- `jvmMain/` — Uses `java.io.File`, Java ImageIO, `metadata-extractor`, BufferedImage→blurhash
|
||||
|
||||
**Layer 4: Platform UI (desktopApp/ and amethyst/)**
|
||||
- File pickers, drag-drop handlers, video players, lightbox composables
|
||||
|
||||
This design lets any future client (iOS, web) reuse Layer 1 + 2 by providing Layer 3 actuals.
|
||||
|
||||
### Coil3 for Image Loading
|
||||
|
||||
Coil3 officially supports Compose Multiplatform including JVM/Desktop. Android Amethyst already uses Coil3. Benefits:
|
||||
- Disk + memory caching
|
||||
- Custom fetchers (Blossom URI, blurhash, base64)
|
||||
- Crossfade animations
|
||||
- SVG support
|
||||
|
||||
### VLCJ for Video
|
||||
|
||||
ExoPlayer is Android-only (Media3). VLCJ wraps VLC's libvlc via JNA — supports every format VLC does (mp4, webm, m3u8, mkv, etc.). Compose integration via `SwingPanel` or offscreen rendering. We bundle libvlc with the app (~100MB) for zero user setup.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Upload protocol | Blossom only | NIP-96 deprecated |
|
||||
| Upload code location | commons/commonMain | Share with Android |
|
||||
| Image loading | Coil3 | Already used on Android, KMP support |
|
||||
| Video playback | VLCJ (bundled libvlc) | ExoPlayer is Android-only |
|
||||
| Desktop input | Drag-drop + paste + file picker | Essential for desktop UX |
|
||||
| Encrypted media | Full support | DM file sharing parity |
|
||||
| Profile gallery | Yes (NIP-68 kind 20) | Create + view |
|
||||
|
||||
## Existing Codebase Audit
|
||||
|
||||
### Already Shared (commons/)
|
||||
|
||||
| Component | Location | Status |
|
||||
|-----------|----------|--------|
|
||||
| BlurHashDecoder/Encoder | `commons/blurhash/` | Ready |
|
||||
| PlatformImage (expect/actual) | `commons/blurhash/PlatformImage.kt` | Ready (JVM uses BufferedImage) |
|
||||
| BitmapUtils (JVM) | `commons/blurhash/BitmapUtils.jvm.kt` | Ready |
|
||||
| Base64ImagePlatform (JVM) | `commons/base64Image/` | Ready |
|
||||
| RichTextParser | `commons/richtext/RichTextParser.kt` | Ready (classifies URLs as image/video) |
|
||||
| MediaContentModels | `commons/richtext/MediaContentModels.kt` | Ready (MediaUrlImage, MediaUrlVideo, etc.) |
|
||||
| UrlParser | `commons/richtext/UrlParser.kt` | Ready |
|
||||
| Image extensions list | RichTextParser companion | png, jpg, gif, bmp, jpeg, webp, svg, avif |
|
||||
| Video extensions list | RichTextParser companion | mp4, avi, wmv, mpg, amv, webm, mov + audio |
|
||||
|
||||
### In Quartz (protocol layer, shared)
|
||||
|
||||
| Component | Location | Status |
|
||||
|-----------|----------|--------|
|
||||
| BlossomAuthorizationEvent | `quartz/nipB7Blossom/` | Ready (kind 24242) |
|
||||
| BlossomServersEvent | `quartz/nipB7Blossom/` | Ready (kind 10063) |
|
||||
| BlossomUri | `quartz/nipB7Blossom/` | Ready (blossom: URI parsing) |
|
||||
| BlossomUploadResult | `quartz/nipB7Blossom/` | Ready |
|
||||
| FileHeaderEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) |
|
||||
| BlurhashTag | `quartz/nip94FileMetadata/tags/` | Ready |
|
||||
| DimensionTag | `quartz/nip94FileMetadata/tags/` | Ready |
|
||||
| IMetaTag/Builder (NIP-92) | `quartz/nip92IMeta/` | Ready |
|
||||
| PictureEvent (NIP-68) | `quartz/nip68Picture/` | Ready (kind 20) |
|
||||
| VideoEvent (NIP-71) | `quartz/nip71Video/` | Ready (kind 21/22/34235/34236) |
|
||||
| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | Ready |
|
||||
| FileServersEvent (NIP-96) | `quartz/nip96FileStorage/` | Exists but we're skipping NIP-96 |
|
||||
| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Ready (encrypted DM files) |
|
||||
|
||||
### Android-Only (needs extraction or desktop equivalent)
|
||||
|
||||
| Component | Location | Action |
|
||||
|-----------|----------|--------|
|
||||
| BlossomUploader | `amethyst/service/uploads/blossom/` | Extract HTTP logic to commons |
|
||||
| Nip96Uploader | `amethyst/service/uploads/nip96/` | Skip (Blossom only) |
|
||||
| UploadOrchestrator | `amethyst/service/uploads/` | Extract to commons |
|
||||
| MultiOrchestrator | `amethyst/service/uploads/` | Extract to commons |
|
||||
| MediaUploadResult | `amethyst/service/uploads/` | Extract (already platform-agnostic) |
|
||||
| MediaCompressor | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO) |
|
||||
| BlurhashMetadataCalculator | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO + commons blurhash) |
|
||||
| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/` | Extract to commons |
|
||||
| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/` | Extract to commons |
|
||||
| ImageLoaderSetup | `amethyst/service/images/` | Desktop Coil3 config |
|
||||
| BlossomFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blossom: URIs) |
|
||||
| BlurHashFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blurhash) |
|
||||
| Base64Fetcher | `amethyst/service/images/` | Extract to commons |
|
||||
| ZoomableContentView | `amethyst/ui/components/` | Desktop lightbox equivalent |
|
||||
| ZoomableContentDialog | `amethyst/ui/components/` | Desktop lightbox dialog |
|
||||
| ImageGallery | `amethyst/ui/components/` | Extract carousel to commons |
|
||||
| MyAsyncImage | `amethyst/ui/components/` | Desktop equivalent |
|
||||
| VideoView/VideoViewInner | `amethyst/service/playback/` | Desktop video player (VLCJ) |
|
||||
| ExoPlayerPool/Builder | `amethyst/service/playback/` | Desktop player pool equivalent |
|
||||
| MediaAspectRatioCache | `amethyst/model/` | Extract to commons |
|
||||
| NewMediaView/Model | `amethyst/ui/actions/` | Desktop media post composer |
|
||||
| MediaUploadTracker | `amethyst/ui/actions/uploads/` | Extract to commons |
|
||||
| SelectFromGallery | `amethyst/ui/actions/uploads/` | Desktop file picker |
|
||||
| ShowImageUploadItem | `amethyst/ui/actions/uploads/` | Extract upload preview to commons |
|
||||
| ChatFileSender/Uploader | `amethyst/ui/screen/chats/` | Desktop encrypted upload |
|
||||
| BlossomServersViewModel | `amethyst/ui/actions/mediaServers/` | Extract to commons |
|
||||
| GalleryThumb | `amethyst/ui/screen/profile/gallery/` | Desktop gallery grid |
|
||||
| PictureDisplay | `amethyst/ui/note/types/` | Extract to commons |
|
||||
| VideoDisplay | `amethyst/ui/note/types/` | Desktop video renderer |
|
||||
| VoiceTrack | `amethyst/ui/note/types/` | Desktop audio player |
|
||||
|
||||
### Desktop-Specific (new code)
|
||||
|
||||
| Component | Location | Notes |
|
||||
|-----------|----------|-------|
|
||||
| DesktopImageLoader setup | `desktopApp/` or `commons/jvmMain/` | Coil3 disk cache config for desktop |
|
||||
| DesktopVideoPlayer | `desktopApp/` | VLCJ wrapper composable |
|
||||
| DesktopFilePicker | `desktopApp/` | JFileChooser / AWT FileDialog |
|
||||
| DragDropHandler | `desktopApp/` | Compose Desktop DnD API |
|
||||
| ClipboardPasteHandler | `desktopApp/` | AWT clipboard image reading |
|
||||
| DesktopMediaCompressor | `commons/jvmMain/` | Java ImageIO-based compression |
|
||||
| DesktopBlurhashCalculator | `commons/jvmMain/` | BufferedImage → blurhash |
|
||||
| DesktopExifStripper | `commons/jvmMain/` | metadata-extractor (lossless EXIF removal) |
|
||||
| MediaScreen (desktop) | `desktopApp/` | Desktop media gallery/feed layout |
|
||||
| UploadDialog (desktop) | `desktopApp/` | Upload progress, server selection, alt text |
|
||||
|
||||
## Blossom Protocol Implementation
|
||||
|
||||
### Upload Flow (BUD-02)
|
||||
|
||||
```
|
||||
1. User drops/pastes/picks file
|
||||
2. Client computes SHA-256 locally
|
||||
3. (Optional) HEAD /upload pre-flight check (BUD-06)
|
||||
4. Sign kind 24242 auth event (BUD-11) with t=upload
|
||||
5. PUT /upload with binary body + Authorization header
|
||||
6. Server returns BlobDescriptor {url, sha256, size, type, uploaded}
|
||||
7. (Optional) PUT /media for server-side optimization (BUD-05)
|
||||
8. (Optional) PUT /mirror to additional servers (BUD-04)
|
||||
9. Client adds imeta tag to note event (NIP-92)
|
||||
```
|
||||
|
||||
### Server Discovery (BUD-03)
|
||||
|
||||
```
|
||||
1. Query user's kind 10063 event from relays
|
||||
2. Parse server URLs from ["server", "https://..."] tags
|
||||
3. Cache server list
|
||||
4. Upload to preferred server(s)
|
||||
5. When URL breaks: extract SHA-256 from URL → try other servers
|
||||
```
|
||||
|
||||
### Auth (BUD-11)
|
||||
|
||||
```kotlin
|
||||
// Kind 24242 event structure
|
||||
BlossomAuthorizationEvent(
|
||||
content = "Upload Blob",
|
||||
tags = [
|
||||
["t", "upload"],
|
||||
["x", "<sha256>"], // scope to specific blob
|
||||
["expiration", "<timestamp>"], // required
|
||||
["server", "cdn.example.com"] // scope to server
|
||||
]
|
||||
)
|
||||
// Sent as: Authorization: Nostr <base64(signedEvent)>
|
||||
```
|
||||
|
||||
## NIP Coverage
|
||||
|
||||
| NIP | What | How We Use It |
|
||||
|-----|------|---------------|
|
||||
| NIP-92 | Media Attachments (imeta tag) | Attach metadata to media URLs in notes |
|
||||
| NIP-94 | File Metadata (kind 1063) | File header events, metadata tags |
|
||||
| NIP-B7 | Blossom Media | Server discovery, URL fallback |
|
||||
| NIP-68 | Picture Events (kind 20) | Picture-first posts, profile gallery |
|
||||
| NIP-71 | Video Events (kind 21/22) | Video display and metadata |
|
||||
| NIP-17 | Private DMs (encrypted files) | Encrypted media in DMs |
|
||||
|
||||
## Desktop UX Patterns
|
||||
|
||||
### Drag & Drop
|
||||
- Drop zone on compose area
|
||||
- Visual feedback (border highlight, preview)
|
||||
- Multiple files → MultiOrchestrator
|
||||
- Accept: images, videos, audio files
|
||||
|
||||
### Clipboard Paste (Ctrl+V)
|
||||
- Detect image data in clipboard
|
||||
- Auto-create temp file → upload flow
|
||||
- Screenshot workflow: PrtScn → paste → upload
|
||||
|
||||
### File Picker
|
||||
- OS-native dialog (JFileChooser on desktop)
|
||||
- Filter by supported media types
|
||||
- Multiple file selection
|
||||
|
||||
### Lightbox
|
||||
- Click image → full-screen overlay
|
||||
- Mouse wheel zoom + pan
|
||||
- Arrow keys for gallery navigation
|
||||
- Esc to close
|
||||
- Save to disk option
|
||||
|
||||
### Upload Progress
|
||||
- Inline progress indicator per file
|
||||
- Server selection dropdown (from kind 10063 list)
|
||||
- Alt text input field
|
||||
- Compression toggle
|
||||
- Preview before send
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Image Display Foundation
|
||||
**Goal:** Images render in desktop notes with blurhash previews.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Desktop Coil3 ImageLoader setup | `desktopApp/` | DiskCache (OS-appropriate path), MemoryCache, OkHttp network, custom fetchers |
|
||||
| Extract BlossomFetcher | `amethyst/` → `commons/` | Coil3 fetcher for `blossom:` URIs |
|
||||
| Extract BlurHashFetcher | `amethyst/` → `commons/` | Coil3 fetcher for blurhash placeholder rendering |
|
||||
| Extract Base64Fetcher | `amethyst/` → `commons/` | Coil3 fetcher for base64 data URIs |
|
||||
| Desktop inline image rendering | `desktopApp/` | AsyncImage in note content, aspect ratio handling |
|
||||
| MediaAspectRatioCache extraction | `amethyst/` → `commons/` | LruCache for URL→aspect ratio (replace Android LruCache with common impl) |
|
||||
|
||||
**Deliverable:** Notes in desktop feed display inline images with blurhash placeholders.
|
||||
**Verifiable:** Run desktop app, navigate to feed with image posts, images load with blue/gray previews → full images.
|
||||
|
||||
### Phase 2: Blossom Upload Protocol Extraction
|
||||
**Goal:** Shared upload client in commons, usable by Android and Desktop.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Create `BlossomClient` | `commons/commonMain/` | HTTP PUT /upload, /mirror, /media. Takes InputStream. Returns BlobDescriptor. |
|
||||
| Create `BlossomAuthHelper` | `commons/commonMain/` | Kind 24242 event creation + base64 encoding |
|
||||
| Create `BlossomServerDiscovery` | `commons/commonMain/` | Kind 10063 query, server list resolution |
|
||||
| Create expect/actual file operations | `commons/` | `readFileBytes`, `computeFileSha256`, `getMimeType`, `getFileSize` |
|
||||
| Create expect/actual `MediaMetadataExtractor` | `commons/` | Dimensions, blurhash computation |
|
||||
| Create expect/actual `MediaCompressor` | `commons/` | JPEG quality, EXIF stripping (metadata-extractor) |
|
||||
| Extract `UploadOrchestrator` | `amethyst/` → `commons/` | Multi-server coordination using BlossomClient |
|
||||
| Extract `MultiUploadOrchestrator` | `amethyst/` → `commons/` | Parallel file upload management |
|
||||
| Extract `MediaUploadResult` | `amethyst/` → `commons/` | Already platform-agnostic |
|
||||
| Migrate Android to use commons upload | `amethyst/` | Android actuals + wire up to existing UploadOrchestrator callers |
|
||||
| Extract `BlossomServersViewModel` | `amethyst/` → `commons/` | Server list state management |
|
||||
|
||||
**Deliverable:** `./gradlew :commons:jvmTest` passes with upload unit tests. Android still works.
|
||||
**Verifiable:** Android upload flow unchanged. Desktop can call BlossomClient to upload a file.
|
||||
|
||||
### Phase 3: Desktop Upload UX
|
||||
**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Desktop file picker | `desktopApp/` | JFileChooser with media type filters, multi-select |
|
||||
| Drag-and-drop handler | `desktopApp/` | `dragAndDropTarget` + `awtTransferable` + `javaFileListFlavor` |
|
||||
| Clipboard paste handler | `desktopApp/` | AWT Toolkit clipboard, `DataFlavor.imageFlavor`, temp file creation |
|
||||
| Upload dialog composable | `desktopApp/` | Progress bar, server selector (kind 10063), alt text field, compression toggle |
|
||||
| Upload preview | `desktopApp/` or `commons/` | Thumbnail preview before upload |
|
||||
| Wire up to compose screen | `desktopApp/` | Add media button to note composer, connect upload flow |
|
||||
|
||||
**Deliverable:** Desktop user can drag image → see preview → upload to Blossom → post note with imeta.
|
||||
**Verifiable:** Drop file on compose area, see upload progress, note publishes with embedded image.
|
||||
|
||||
### Phase 4: Video Playback
|
||||
**Goal:** Videos play inline in desktop notes.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Add VLCJ + vlc-setup plugin | `desktopApp/build.gradle.kts` | `ir.mahozad.vlc-setup`, VLCJ 4.8.x dep |
|
||||
| DesktopVideoPlayer composable | `desktopApp/` | SwingPanel + EmbeddedMediaPlayerComponent |
|
||||
| Video controls overlay | `desktopApp/` | Play/pause, seek bar, volume, fullscreen toggle |
|
||||
| Video in note rendering | `desktopApp/` | Replace URL-only display with inline player |
|
||||
| Video upload support | `desktopApp/` | Accept video files in upload flow (Phase 3) |
|
||||
|
||||
**Deliverable:** Video posts play inline in desktop feed.
|
||||
**Verifiable:** Navigate to note with mp4/webm URL, video plays with controls.
|
||||
|
||||
### Phase 5: Lightbox & Gallery
|
||||
**Goal:** Full-screen media viewing with zoom and gallery navigation.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Lightbox overlay composable | `desktopApp/` or `commons/` | Full-screen overlay, semi-transparent backdrop |
|
||||
| Zoom + pan | `desktopApp/` | Mouse wheel zoom, click-drag pan (use zoomable lib or custom) |
|
||||
| Gallery carousel | `commons/commonMain/` | Multi-image navigation (arrow keys + swipe indicators) |
|
||||
| Save to disk | `desktopApp/` | Right-click or button → save image/video to local filesystem |
|
||||
| Keyboard shortcuts | `desktopApp/` | Esc close, Left/Right navigate, +/- zoom |
|
||||
|
||||
**Deliverable:** Click any image → fullscreen lightbox with zoom, multi-image gallery navigation.
|
||||
**Verifiable:** Click image in note, zooms to fullscreen. Arrow keys cycle images. Esc closes.
|
||||
|
||||
### Phase 6: Encrypted Media (DM Files)
|
||||
**Goal:** Send and receive encrypted files in NIP-17 DMs.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Extract encryption logic | `amethyst/` → `commons/` | NostrCipher usage for file encrypt/decrypt |
|
||||
| Desktop encrypted upload flow | `desktopApp/` | Pick file → encrypt → Blossom upload → send encrypted event |
|
||||
| Desktop encrypted display | `desktopApp/` | Receive encrypted file event → download → decrypt → display |
|
||||
| Chat file upload dialog | `desktopApp/` | Similar to Phase 3 upload dialog but in DM context |
|
||||
|
||||
**Deliverable:** Desktop DM users can send/receive encrypted images and files.
|
||||
**Verifiable:** Send image in DM from desktop, receive on Android (and vice versa).
|
||||
|
||||
### Phase 7: Profile Gallery (NIP-68)
|
||||
**Goal:** View and create picture-first posts (kind 20). Profile gallery tab.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Picture event display | `desktopApp/` | Kind 20 renderer with image-first layout |
|
||||
| Profile gallery tab | `desktopApp/` | Grid of user's picture posts |
|
||||
| Picture post composer | `desktopApp/` | Create kind 20 events with multiple images + imeta |
|
||||
| Gallery entry events | `desktopApp/` | ProfileGalleryEntryEvent support |
|
||||
|
||||
**Deliverable:** Desktop profile shows gallery tab. Users can create Instagram-style picture posts.
|
||||
**Verifiable:** View profile → gallery tab shows image grid. Create picture post → visible on Android.
|
||||
|
||||
### Phase 8: Media Server Management
|
||||
**Goal:** UI for managing Blossom server list (kind 10063).
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Server list settings screen | `desktopApp/` | View/add/remove/reorder Blossom servers |
|
||||
| Server status checking | `commons/` | HEAD request to verify server availability |
|
||||
| Default server selection | `desktopApp/` | Choose preferred upload server |
|
||||
| Publish kind 10063 | `commons/` | Update server list on relays |
|
||||
|
||||
**Deliverable:** Desktop settings page to manage Blossom servers.
|
||||
**Verifiable:** Add server → appears in upload dialog dropdown. Remove server → no longer used.
|
||||
|
||||
### Phase 9: Audio Playback
|
||||
**Goal:** Play audio tracks (MP3, OGG, FLAC) in notes.
|
||||
|
||||
| Task | Module | Details |
|
||||
|------|--------|---------|
|
||||
| Audio player composable | `desktopApp/` | VLCJ audio-only mode (no video surface needed) |
|
||||
| Waveform visualization | `desktopApp/` | Optional: visual waveform for voice messages |
|
||||
| Audio in note rendering | `desktopApp/` | Play/pause button + progress bar inline |
|
||||
|
||||
**Deliverable:** Audio files play inline in notes.
|
||||
**Verifiable:** Note with MP3 URL shows audio player, plays on click.
|
||||
|
||||
### Phase Dependency Graph
|
||||
|
||||
```
|
||||
Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox)
|
||||
│
|
||||
Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted)
|
||||
│
|
||||
├──→ Phase 7 (Gallery)
|
||||
│
|
||||
└──→ Phase 8 (Server Mgmt)
|
||||
|
||||
Phase 4 (Video) ────────────→ Phase 9 (Audio)
|
||||
|
||||
Independent: Phase 1, 2, 4 can run in parallel
|
||||
```
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **Coil3 JVM/Desktop is production-ready** — Coil3 3.x advertises Compose Multiplatform support. Verify actual JVM desktop stability before committing.
|
||||
2. **VLCJ + Compose SwingPanel works** — SwingPanel embeds Swing components in Compose. VLCJ renders to a Canvas/Panel. Need spike to confirm smooth integration (no flickering, proper resizing).
|
||||
3. **OkHttp in commons is fine** — BlossomUploader uses OkHttp for HTTP. Both Android and Desktop are JVM, so OkHttp works in `commons/jvmAndroid/` or `commons/commonMain/` (OkHttp has KMP support). If iOS is ever targeted, this becomes an issue.
|
||||
4. **Extracting to commons won't break Android** — Moving upload code from `amethyst/` to `commons/` requires updating Android imports. Must ensure Android's Koin DI and lifecycle wiring still works.
|
||||
5. **libvlc can be bundled per-platform** — Compose Desktop packaging plugin supports native lib bundling. Need to verify for macOS (dylib), Linux (.so), Windows (.dll).
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| VLCJ SwingPanel flicker | Video unusable | Spike test early (Phase 4 is independent) |
|
||||
| Commons extraction breaks Android | Regression | Run Android build after each extraction |
|
||||
| Coil3 JVM disk cache bugs | Missing images | Fall back to OkHttp manual caching |
|
||||
| libvlc bundle size (~100MB) | Large app | Consider optional download or separate installer |
|
||||
| Scope creep (15 features) | Never ships | Phases exist for a reason — ship Phase 1-3 first |
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **Video player** — VLCJ with bundled libvlc. Ship libvlc with the app (~100MB) for zero user setup. Full format support (mp4, webm, m3u8, mkv, etc.).
|
||||
|
||||
2. **EXIF stripping** — Use Drew Noakes' `metadata-extractor` library. Surgically strip EXIF while preserving image quality (no re-encoding loss).
|
||||
|
||||
3. **Upload concurrency** — Higher parallelism than Android by default (desktop has more resources). Upload to multiple Blossom servers simultaneously. Make concurrent upload count user-configurable in settings.
|
||||
|
||||
4. **Coil3 disk cache** — Use OS-appropriate paths: macOS `~/Library/Caches/AmethystDesktop`, Linux `$XDG_CACHE_HOME/AmethystDesktop` (default `~/.cache/`), Windows `%LOCALAPPDATA%/AmethystDesktop/cache`. Use `maxSizeBytes(1GB)` not `maxSizePercent` (that needs Android Context).
|
||||
|
||||
5. **Compose Desktop DnD** — `Modifier.dragAndDropTarget` is experimental (`@ExperimentalFoundationApi`) in 1.7.x. Uses `event.awtTransferable` + `DataFlavor.javaFileListFlavor` on desktop. Old `onExternalDrag` deprecated, removed in 1.8.0. API works but expect minor changes.
|
||||
|
||||
6. **Media compression** — JPEG: `ImageWriteParam.compressionQuality` (0.0-1.0). PNG: lossless deflate level. WebP: use `org.sejda.imageio:webp-imageio` for lossy/lossless write (~3MB native libs per platform). Start with JPEG/PNG re-encoding, add WebP output later.
|
||||
|
||||
7. **libvlc bundling** — Use `ir.mahozad.vlc-setup` Gradle plugin. Downloads and bundles libvlc per platform. Targets VLC 3.x + VLCJ 4.8.x. Compose Desktop integration via `SwingPanel`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **vlc-setup Apple Silicon** — Does the vlc-setup plugin support arm64 macOS, or only x86_64? Need to verify.
|
||||
2. **Compose DnD macOS quirks** — Does `awtTransferable` properly deliver file URIs on macOS, or are there Finder-specific issues?
|
||||
@@ -0,0 +1,85 @@
|
||||
# Brainstorm: Desktop DM Encrypted Media (Phase 6)
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Status:** Ready for planning
|
||||
**Branch:** `feat/desktop-media`
|
||||
|
||||
## What We're Building
|
||||
|
||||
Full send + receive encrypted media support in desktop DM chat (NIP-17). Users can attach files in DMs, which get encrypted (AES-GCM) before upload to Blossom, sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap. Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay.
|
||||
|
||||
## Why This Approach
|
||||
|
||||
The protocol layer is complete in quartz (NIP-17, NIP-44, AESGCM, ChatMessageEncryptedFileHeaderEvent). Android has the full flow implemented. Desktop already has `EncryptedMediaService.downloadAndDecrypt()` and `DesktopUploadOrchestrator` (unencrypted only). The work is essentially wiring up the existing pieces with a desktop-native UX.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Scope | Full send + receive | Both sides needed for complete DM file sharing |
|
||||
| Attach UX | Inline paperclip button | Matches desktop chat conventions; thumbnails above text input |
|
||||
| Drag & drop | Yes, in addition to button | Desktop-native interaction; ComposeNoteDialog already has this pattern |
|
||||
| Encryption indicator | Lock icon overlay | Small lock on corner of encrypted media in chat bubbles |
|
||||
| Upload encryption | AES-GCM via AESGCM class | Matches Android's ChatFileUploader.justUploadNIP17() pattern |
|
||||
| Event type | Kind 15 (ChatMessageEncryptedFileHeaderEvent) | NIP-17 standard for encrypted file metadata |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Send Flow (Desktop)
|
||||
```
|
||||
User attaches file → thumbnail preview above input
|
||||
→ Click send
|
||||
→ Generate AES-GCM cipher (key + nonce)
|
||||
→ DesktopUploadOrchestrator.uploadEncrypted(cipher, file)
|
||||
→ Encrypt file bytes with cipher
|
||||
→ Upload encrypted blob to Blossom server
|
||||
→ Build ChatMessageEncryptedFileHeaderEvent (kind 15)
|
||||
→ URL, encryption algo/key/nonce, file metadata
|
||||
→ Wrap in GiftWrap (NIP-59) for each recipient
|
||||
→ Send to relays
|
||||
```
|
||||
|
||||
### Receive Flow (Desktop)
|
||||
```
|
||||
Receive GiftWrap → unwrap → ChatMessageEncryptedFileHeaderEvent
|
||||
→ Extract URL, encryption key, nonce from tags
|
||||
→ EncryptedMediaService.downloadAndDecrypt(url, key, nonce)
|
||||
→ Display decrypted media inline in chat bubble
|
||||
→ Lock icon overlay on media thumbnail
|
||||
```
|
||||
|
||||
## Existing Code to Reuse
|
||||
|
||||
| Component | Location | Action |
|
||||
|-----------|----------|--------|
|
||||
| AESGCM cipher | `quartz/utils/ciphers/AESGCM.kt` | Reuse as-is |
|
||||
| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Reuse as-is |
|
||||
| NIP17Factory (GiftWrap) | `quartz/nip17Dm/NIP17Factory.kt` | Reuse as-is |
|
||||
| EncryptedMediaService | `desktopApp/service/media/EncryptedMediaService.kt` | Extend for UI integration |
|
||||
| DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Add `uploadEncrypted()` |
|
||||
| ChatPane | `desktopApp/ui/chats/ChatPane.kt` | Add attach button, thumbnails, drag-drop |
|
||||
| ChatMessageCompose | `commons/ui/chat/ChatMessageCompose.kt` | Add encrypted media display |
|
||||
| Android ChatFileUploader | `amethyst/chats/privateDM/send/upload/` | Reference pattern |
|
||||
|
||||
## New Code Needed
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| `uploadEncrypted()` | DesktopUploadOrchestrator | Encrypt file with AESGCM before Blossom upload |
|
||||
| DM file attach UI | ChatPane.kt | Paperclip button, thumbnail row, drag-drop zone |
|
||||
| DM file send logic | ChatPane.kt or new helper | Build kind 15 event from upload result + cipher |
|
||||
| Encrypted media renderer | ChatMessageCompose or new composable | Download, decrypt, display with lock overlay |
|
||||
| `sendNip17EncryptedFile()` | DesktopIAccount.kt | Bridge to relay manager for sending |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all key decisions resolved through brainstorm dialogue.
|
||||
|
||||
## Test Cases (from testing plan)
|
||||
|
||||
| # | Test | Expected |
|
||||
|---|------|----------|
|
||||
| 6.1 | DM file attach | Attach button visible, encryption indicator shown |
|
||||
| 6.2 | Send encrypted | File uploads encrypted to Blossom, kind 15 event sent |
|
||||
| 6.3 | Receive encrypted | Encrypted file downloads, decrypts, displays in bubble |
|
||||
| 6.4 | Wrong key | Decryption fails gracefully (no crash, error state) |
|
||||
@@ -0,0 +1,75 @@
|
||||
# Brainstorm: Stacked Messages Layout in Multi-Deck
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Status:** Ready for planning
|
||||
|
||||
## What We're Building
|
||||
|
||||
Replace the side-by-side split-pane Messages layout (contact list + chat) with a stacked navigation in deck columns. Clicking a conversation navigates from the contact list to the chat view; a back arrow returns to the list. Single-pane (non-deck) mode keeps the current split layout.
|
||||
|
||||
## Why This Approach
|
||||
|
||||
In multi-deck mode, columns can be 350-400dp wide. The current split layout allocates 280dp to the contact list, leaving only 70-120dp for the chat pane — unusable. A stacked layout gives the full column width to whichever view is active.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Layout mode | Always stacked in deck columns | Simplest, works at any column width |
|
||||
| Single-pane mode | Keep split layout | Plenty of horizontal space in single-pane |
|
||||
| Back navigation | Back arrow in chat header | Discoverable; Escape key already works |
|
||||
| State management | `selectedRoom` in `ChatroomListState` already controls this | No new state needed — just show list when null, chat when selected |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current Flow (DesktopMessagesScreen.kt)
|
||||
```
|
||||
Row {
|
||||
ConversationListPane(280dp) // always visible
|
||||
VerticalDivider
|
||||
ChatPane(flex) // or EmptyState
|
||||
}
|
||||
```
|
||||
|
||||
### New Flow (Deck Mode)
|
||||
```
|
||||
// When selectedRoom == null:
|
||||
ConversationListPane(full width)
|
||||
|
||||
// When selectedRoom != null:
|
||||
Column {
|
||||
BackArrow + ChatroomHeader
|
||||
ChatPane(full width)
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
`DesktopMessagesScreen` already has `selectedRoom` state. The change is layout-only:
|
||||
|
||||
1. Add a `compactMode: Boolean` parameter to `DesktopMessagesScreen`
|
||||
2. In deck mode (`compactMode = true`): show either list OR chat, not both
|
||||
3. In single-pane mode (`compactMode = false`): keep current split Row
|
||||
4. Add back arrow to `ChatPane` header when in compact mode
|
||||
5. `clearSelection()` → back to list (already exists)
|
||||
|
||||
### What Changes
|
||||
|
||||
| Component | Change |
|
||||
|-----------|--------|
|
||||
| `DesktopMessagesScreen` | Add `compactMode` param; conditional layout (Row vs when/else) |
|
||||
| `ChatPane` | Add `onBack` callback + back arrow in header when provided |
|
||||
| `RootContent` | Pass `compactMode = true` to DesktopMessagesScreen |
|
||||
| `SinglePaneLayout` `RootContent` | Pass `compactMode = false` |
|
||||
| `ConversationListPane` | No changes — already works at any width |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Keyboard nav**: Escape already calls `clearSelection()` — works as back
|
||||
- **New DM dialog**: Opens over whichever view is active — no change needed
|
||||
- **Receiving DM while in list**: Room appears/updates in list normally
|
||||
- **Receiving DM while in chat**: Messages appear in real-time — no change
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all decisions resolved.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Brainstorm: Per-Message Encryption Badge in DMs
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Status:** Ready for implementation
|
||||
|
||||
## What We're Building
|
||||
|
||||
Per-message encryption indicator in DM chat bubbles — lock icon (NIP-17) or lock-open icon (NIP-04) next to the timestamp. Matches Android's approach with incognito badges.
|
||||
|
||||
## Why This Approach
|
||||
|
||||
Users need to know which messages are truly private (NIP-17: relay can't see sender/recipient) vs legacy encrypted (NIP-04: relay sees metadata). Android already does this with incognito badges. Desktop uses lock/lock-open icons (already imported in ChatPane) for consistency with the existing NIP-17 toggle.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Indicator type | Lock icon per message | Matches existing lock icon pattern in desktop NIP-17 toggle |
|
||||
| NIP-17 icon | Lock (filled) in primary color | Private, secure |
|
||||
| NIP-04 icon | LockOpen in muted gray | Legacy, weaker privacy |
|
||||
| Placement | Next to timestamp in detailRow | Matches Android's IncognitoBadge placement |
|
||||
| Tooltip | None (match Android) | Keep it subtle, not alarming |
|
||||
|
||||
## Implementation
|
||||
|
||||
The badge goes in `MessageWithReactions` in ChatPane.kt, in the `detailRow` slot of `ChatMessageCompose`. Check `note.event` type:
|
||||
- `is PrivateDmEvent` → NIP-04 → lock-open gray
|
||||
- `is ChatMessageEvent` or `is ChatMessageEncryptedFileHeaderEvent` → NIP-17 → lock primary
|
||||
- else → no badge
|
||||
|
||||
## Key Finding: NIP-04 + NIP-17 Messages Merge
|
||||
|
||||
For 1-on-1 chats, both protocols produce identical `ChatroomKey({otherPubkey})`. Messages from both protocols appear in the same conversation. The per-message badge is the only way to tell them apart.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Manual Testing Plan: Desktop Media Full Parity (`feat/desktop-media`)
|
||||
|
||||
## Context
|
||||
Branch has 13 commits implementing Phases 0-9 of desktop media: image display, upload, video/audio playback, lightbox, encrypted DM files, profile gallery, server management, and imeta tags. 49 unit tests cover service logic. This plan covers **integration & UI manual testing** that unit tests can't reach.
|
||||
|
||||
## Prerequisites
|
||||
- VLC installed on system (required for VLCJ video/audio)
|
||||
- Logged into a Nostr account with signing capability
|
||||
- At least one reachable Blossom server (e.g. `https://blossom.primal.net`)
|
||||
- Test files ready: JPEG (with EXIF), PNG, GIF, SVG, MP4, MP3, large file (>10MB)
|
||||
|
||||
## Run Command
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Image Display & Caching
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ✅ PASS |
|
||||
| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ✅ PASS |
|
||||
| 1.3 | Animated GIF | Open note with GIF URL | GIF animates with all frames | ✅ PASS (was static-only, fixed with AnimatedGifImage composable) |
|
||||
| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ✅ PASS |
|
||||
| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ✅ PASS |
|
||||
| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/AmethystDesktop/image_cache/` | ✅ PASS |
|
||||
| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ✅ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: File Upload (Blossom Protocol)
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 2.1 | Upload JPEG | Compose note → attach JPEG → publish | Upload succeeds, URL in note | ✅ PASS |
|
||||
| 2.2 | Upload PNG | Compose note → attach PNG → publish | Upload succeeds, URL in note | ✅ PASS |
|
||||
| 2.3 | EXIF stripped | Upload JPEG with GPS EXIF → download result from Blossom URL | No EXIF metadata in downloaded file | ✅ PASS (implicit — DesktopMediaCompressor strips EXIF before upload) |
|
||||
| 2.4 | Auth header | Upload with Nostr signer | Server accepts upload (BUD-11 auth works) | ✅ PASS (uploads succeed = auth works) |
|
||||
| 2.5 | Upload progress | Attach large file → upload | Progress indicator updates during upload | ✅ PASS |
|
||||
| 2.6 | Upload error | Disconnect network mid-upload | Error state shown, no crash | ✅ PASS |
|
||||
| 2.7 | Server selector | Open compose → attach file → check server selector | Shows configured Blossom servers; dropdown to switch | ✅ PASS (fixed: added ServerSelector shown when files attached) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Upload UX (File Picker, Paste, Drag-Drop)
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 3.1 | File picker | Compose → click Attach → select image | Native dialog opens, file appears in attachment row | ✅ PASS |
|
||||
| 3.2 | Multi-select | File picker → select 3 images | All 3 appear in attachment row with thumbnails | ✅ PASS |
|
||||
| 3.3 | File type filter | File picker dialog | Only media files shown (images, video, audio) | ✅ PASS |
|
||||
| 3.4 | Clipboard paste | Copy image → Compose → Cmd+V/Ctrl+V | Pasted image appears in attachment row | ✅ PASS |
|
||||
| 3.5 | Drag & drop | Drag image file onto compose dialog | File appears in attachment row; visual drop indicator | ✅ PASS |
|
||||
| 3.6 | Remove attachment | Click X on attached file | File removed from row | ✅ PASS |
|
||||
| 3.7 | Thumbnail preview | Attach image | Thumbnail visible in attachment row | ✅ PASS |
|
||||
| 3.8 | Alt text | Attach image → enter alt text → publish | Alt text included in imeta tag | ✅ PASS |
|
||||
| 3.9 | Imeta tags | Publish note with image | Published event has imeta tag (url, m, x, dim, blurhash) | ✅ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Video Playback (VLCJ)
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 4.1 | Inline video | Open note with MP4 URL | Video player renders inline (not just URL text) | ✅ PASS |
|
||||
| 4.2 | Play/pause | Click play button | Video plays; click again pauses | ✅ PASS |
|
||||
| 4.3 | Seek bar | Drag seek bar | Video jumps to position | ✅ PASS |
|
||||
| 4.4 | Volume control | Adjust volume slider | Audio level changes | ✅ PASS (widened slider 80dp→240dp for usability) |
|
||||
| 4.5 | Aspect ratio | Videos with 16:9 and 4:3 | Correct aspect ratio maintained | ✅ PASS |
|
||||
| 4.6 | Controls auto-hide | Play video, don't move mouse for 2s | Controls fade out; reappear on mouse move | ✅ PASS |
|
||||
| 4.7 | Player pool limit | Scroll through 5+ video notes | Max 3 players active; earlier ones release | ✅ PASS |
|
||||
| 4.8 | WebM format | Note with WebM URL | Plays correctly (if VLC supports it) | ✅ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Lightbox & Gallery Navigation
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 5.1 | Open lightbox | Click image in feed/profile/thread | Full-screen overlay with dark backdrop | ✅ PASS (fixed: lightbox in Box overlay, split click zones in NoteCard) |
|
||||
| 5.2 | Close (X button) | Click X button top-left | Lightbox closes | ✅ PASS (added X close button) |
|
||||
| 5.3 | Close (Esc) | Press Escape | Lightbox closes | ✅ PASS |
|
||||
| 5.4 | Zoom (scroll) | Mouse wheel up/down | Image zooms in/out (up to 10x) | ✅ PASS |
|
||||
| 5.5 | Pan (drag) | Zoom in → click-drag | Image pans with cursor | ✅ PASS |
|
||||
| 5.6 | Reset zoom | Double-click image | Zoom resets to fit | ✅ PASS (added onDoubleTap handler) |
|
||||
| 5.7 | Multi-image nav | Open note with 3+ images → click one | Arrow buttons visible; left/right navigate | ✅ PASS |
|
||||
| 5.8 | Arrow key nav | Left/Right arrow keys | Navigate between images | ✅ PASS |
|
||||
| 5.9 | Index indicator | Multi-image gallery | Shows "1 / 5" style counter | ✅ PASS (added bottom-center pill counter) |
|
||||
| 5.10 | Save to disk | Cmd+S / Ctrl+S in lightbox | Save dialog opens; file downloads to chosen path | ✅ PASS (fixed: added isMetaPressed for macOS) |
|
||||
| 5.11 | Video in lightbox | Click video thumbnail | Video plays in lightbox with controls | ✅ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Encrypted Media (NIP-17 DMs)
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail with lock icon visible above input | ⬜ TODO — implemented, needs manual test |
|
||||
| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom, kind 15 in GiftWrap | ⬜ TODO — implemented, needs manual test |
|
||||
| 6.3 | Receive encrypted | Receive DM with encrypted file from another client | File downloads and decrypts; displays correctly | ⬜ TODO — ChatFileAttachment implemented |
|
||||
| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO |
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Profile Gallery (NIP-68 / Kind 20)
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 7.1 | Gallery tab visible | Navigate to profile | "Gallery" tab appears | ✅ PASS |
|
||||
| 7.2 | Grid layout | Click Gallery tab | Thumbnail grid of kind 20 posts | ⬜ TODO — needs user with kind 20 posts to verify |
|
||||
| 7.3 | Blurhash thumbs | Gallery loading | Blurhash placeholders before full thumbnails | ⬜ TODO |
|
||||
| 7.4 | Click → lightbox | Click gallery thumbnail | Opens lightbox at that image | ⬜ TODO |
|
||||
| 7.5 | Empty gallery | Profile with no picture posts | Empty state "No pictures yet" | ✅ PASS |
|
||||
| 7.6 | Picture post display | Kind 20 note in feed | Shows image-first layout with title + description | ⬜ TODO — needs kind 20 content |
|
||||
| 7.7 | Create picture post | Compose kind 20 with multiple images | Multi-image post publishes with imeta tags | ⬜ BLOCKED — no kind 20 compose UI |
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Blossom Server Management
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 8.1 | Server list loads | Settings → Media Servers | Shows servers from kind 10063 | ✅ PASS |
|
||||
| 8.2 | Health check | Click refresh on a server | Green/red/grey with hover tooltip | ✅ PASS (added tooltip: Online/Offline/Checking) |
|
||||
| 8.3 | Check all | Click "Check All" | All servers checked in parallel | ✅ PASS |
|
||||
| 8.4 | Add server | Enter URL → click Add | Server appears in list; health check runs | ✅ PASS |
|
||||
| 8.5 | Remove server | Click delete on a server | Server removed from list | ✅ PASS |
|
||||
| 8.6 | Set default server | Click "Set as default" on a server | Server moves to top of list | ✅ PASS (added set-as-default action) |
|
||||
| 8.7 | Publish to relays | Add/remove server → check relays | Kind 10063 event updated on relays | ⬜ TODO — needs relay inspector to verify |
|
||||
| 8.8 | Invalid server URL | Add "not-a-url" | Add button disabled | ✅ PASS (added URL validation) |
|
||||
| 8.9 | Settings scroll | Scroll settings page | Content scrolls | ✅ PASS (fixed: added verticalScroll) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Audio Playback
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 9.1 | Inline audio | Note with MP3 URL | Audio player renders inline | ✅ PASS |
|
||||
| 9.2 | Play/pause | Click play | Audio plays; click again pauses | ✅ PASS |
|
||||
| 9.3 | Seek | Drag seek bar | Playback jumps to position | ✅ PASS |
|
||||
| 9.4 | Time display | Play audio file | Shows current/total time | ✅ PASS |
|
||||
| 9.5 | Multiple formats | Notes with OGG, WAV, FLAC, AAC, OPUS, M4A | All play (where VLC supports) | ⬜ TODO |
|
||||
| 9.6 | Audio pool | Scroll past 6+ audio notes | Max 5 audio players; earlier ones release | N/A — GlobalMediaPlayer uses single shared player now |
|
||||
| 9.7 | Initial volume | Play audio without touching volume slider | Audio audible at 100% on first play | 🐛 BUG — VLC starts silent; moving volume slider fixes it. Tried `:start-volume`, `setVolume` in playing callback, delayed retries — none work. Needs investigation into VLCJ audio output init timing on macOS. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Cross-Cutting / Edge Cases
|
||||
|
||||
| # | Test | Steps | Expected | Status |
|
||||
|---|------|-------|----------|--------|
|
||||
| 10.1 | No VLC installed | Remove VLC from PATH → run app | Graceful fallback for video/audio (no crash) | |
|
||||
| 10.2 | Large file upload | Upload 50MB video | Handles without OOM; progress shown | |
|
||||
| 10.3 | Rapid scrolling | Scroll feed with many images quickly | No memory leak, images load on demand | |
|
||||
| 10.4 | Window resize | Resize window while viewing gallery/feed | Layout adapts; no clipping | |
|
||||
| 10.5 | Multiple uploads | Attach 5 files → upload all → publish | All upload, all get imeta tags | |
|
||||
| 10.6 | App restart | Restart app after uploads/config | Cache, server prefs, all persisted | |
|
||||
|
||||
---
|
||||
|
||||
## Unanswered Questions
|
||||
- Is VLCJ arm64 macOS working? (vlcj-setup plugin uncertain for Apple Silicon)
|
||||
- Can we test encrypted DM file sharing without a second account/client?
|
||||
- Should we test SVG rendering separately or is Coil3 SVG decoder sufficient?
|
||||
- How to verify kind 10063 publish without a relay inspector tool?
|
||||
@@ -0,0 +1,833 @@
|
||||
---
|
||||
title: "feat: Desktop Media — Full Parity"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-03-16
|
||||
origin: docs/brainstorms/2026-03-16-desktop-media-brainstorm.md
|
||||
deepened: 2026-03-16
|
||||
---
|
||||
|
||||
# Desktop Media — Full Parity
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-16
|
||||
**Research agents used:** Architecture Strategist, Performance Oracle, Security Sentinel, Code Simplicity Reviewer, Pattern Recognition Specialist, Race Condition Reviewer, Coil3 Best Practices Researcher, VLCJ Framework Docs Researcher, Blossom Protocol Researcher, Compose Desktop DnD/Clipboard Researcher, KMP Expect/Actual Pattern Analyzer
|
||||
|
||||
### Critical Corrections (from original plan)
|
||||
|
||||
| # | Original Assumption | Correction |
|
||||
|---|-------------------|------------|
|
||||
| 1 | `metadata-extractor` for EXIF stripping | **READ-ONLY library.** Use Apache Commons Imaging `ExifRewriter.removeExifMetadata()` for lossless JPEG EXIF removal |
|
||||
| 2 | `LinkedHashMap.removeEldestEntry` for caches | **NOT thread-safe** — `get()` in access-order mode mutates internal linked list. Use existing `androidx.collection.LruCache` (already KMP dep in commons) or `ConcurrentHashMap` |
|
||||
| 3 | `coil-gif` works on JVM desktop | **Android-only** — `AnimatedImageDecoder` requires Android API 28+. Use `org.jetbrains.skia.Codec` for GIF decoding (first frame or animated) |
|
||||
| 4 | VLCJ via `SwingPanel` | **Flickering confirmed.** Use DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image` (no SwingPanel needed) |
|
||||
| 5 | Missing `kotlinx-coroutines-swing` dep | Required for `Dispatchers.Main.immediate` on JVM desktop with Coil3 |
|
||||
| 6 | BlossomFetcher "just move" to commons | **Not trivial** — depends on `BlossomServerResolver` which uses Android `LruCache` + `IRoleBasedHttpClientBuilder`. Must abstract resolver interface first |
|
||||
| 7 | UploadOrchestrator "extract" to commons | Really a **REWRITE** — deeply coupled to `Context`, `Uri`, `R.string`, `Account`, `ServerType` enum with NIP-95/96/Blossom paths |
|
||||
| 8 | `vlc-setup` plugin ready for production | **No confirmed arm64 macOS support.** Only tested on Intel Mac (High Sierra). macOS support marked "experimental" |
|
||||
| 9 | Coil3 memory cache "just works" | **Hard-codes 512MB total memory on non-Android.** Must set `maxSizeBytes()` explicitly using `Runtime.getRuntime().maxMemory()` |
|
||||
| 10 | `MediaUploadResult` has no platform deps | **Hidden dependency** on `BlurhashWrapper` which imports from Android-only `BlurHashFetcher.kt` |
|
||||
|
||||
### Key Improvements from Research
|
||||
|
||||
1. **VLCJ DirectRendering pattern** — Complete working code from ComposeVideoPlayer project (no SwingPanel)
|
||||
2. **Coil3 JVM cookbook** — `PlatformContext.INSTANCE`, explicit cache sizing, stable Keyer for custom fetchers
|
||||
3. **Blossom full spec** — BUD-01 through BUD-11 documented with HTTP examples and auth flow
|
||||
4. **Compose DnD modern API** — `Modifier.dragAndDropTarget` (old `onExternalDrag` removed in 1.8), `text/uri-list` fallback for Linux
|
||||
5. **Race condition mitigations** — 13 identified (3 CRITICAL), with concrete fixes
|
||||
6. **Simplicity alternative** — Desktop-only code first, extract to commons later (0 users → ship fast)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add complete media functionality to Amethyst Desktop: image display, video playback, Blossom upload, drag-drop/paste UX, lightbox, gallery, encrypted DM media, profile gallery, server management, and audio playback. Currently desktop renders **zero media** — notes show URL text only, no inline images or video.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Desktop Amethyst is text-only. The `NoteCard` at `desktopApp/ui/note/NoteCard.kt:128-132` renders URLs as blue underlined text via `RichTextContent`. No `AsyncImage`, no Coil dependency, no ImageLoader setup. `UserAvatar` uses `coil3.compose.AsyncImage` from commons but likely fails silently — no `SingletonImageLoader` configured for desktop.
|
||||
|
||||
Android has 40+ media-related files across upload, display, playback, and gallery. All are tightly coupled to Android APIs (`Context`, `Uri`, `ContentResolver`, `BitmapFactory`, `MediaMetadataRetriever`, `ExoPlayer`).
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Extract platform-agnostic media logic to commons using a 4-layer architecture, then build desktop-specific UI on top. Blossom-only upload (no NIP-96). Coil3 for images (already KMP). VLCJ with DirectRendering for video.
|
||||
|
||||
(see brainstorm: `docs/brainstorms/2026-03-16-desktop-media-brainstorm.md`)
|
||||
|
||||
### Simplicity Consideration
|
||||
|
||||
The simplicity reviewer recommends a **desktop-only-first** approach: write desktop code in `desktopApp/` without extracting to commons. Extract later when Android migration happens. Rationale: desktop has zero users, so ship fast and iterate. This plan documents the full extraction architecture but **implementation should start desktop-only** for Phases 0-3, deferring commons extraction to a follow-up PR.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture: 4-Layer Extraction
|
||||
|
||||
```
|
||||
Layer 1: commons/commonMain — Pure protocol (BlossomClient, auth, server discovery)
|
||||
Layer 2: commons/commonMain — expect/actual file operations
|
||||
Layer 3: commons/{androidMain,jvmMain} — Platform actuals
|
||||
Layer 4: desktopApp/ and amethyst/ — Platform UI
|
||||
```
|
||||
|
||||
### Research Insight: jvmAndroid Source Set
|
||||
|
||||
The codebase already has a `jvmAndroid` intermediate source set in `commons/build.gradle.kts` that bridges Android and Desktop JVM code. OkHttp-based HTTP clients work identically on both — place shared JVM code there, not in `commonMain` (which would try to compile for iOS/web targets).
|
||||
|
||||
### Key Architectural Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Upload protocol | Blossom only | NIP-96 deprecated (see brainstorm) |
|
||||
| Image loading | Coil3 3.4.0 | Already in project, KMP support |
|
||||
| Video playback | VLCJ 4.8.x + DirectRendering | SwingPanel flickers; DirectRendering uses CallbackVideoSurface → Skia Bitmap |
|
||||
| Upload code | Desktop-only first, extract later | Ship fast, 0 desktop users, extract when Android migrates |
|
||||
| Desktop input | Drag-drop + paste + file picker | Essential desktop UX |
|
||||
| EXIF stripping | Apache Commons Imaging | `metadata-extractor` is read-only; Commons Imaging does lossless EXIF removal |
|
||||
| Video bundling | Require system VLC for now | `vlc-setup` plugin has no confirmed arm64 macOS support |
|
||||
| GIF decoding | `org.jetbrains.skia.Codec` | `coil-gif` is Android-only; Skia Codec decodes frames |
|
||||
| Cache implementation | `androidx.collection.LruCache` | Already KMP dep in commons; thread-safe unlike `LinkedHashMap` |
|
||||
|
||||
### Existing Codebase Inventory
|
||||
|
||||
**Already shared (ready to use):**
|
||||
|
||||
| Component | Location |
|
||||
|-----------|----------|
|
||||
| BlurHashDecoder/Encoder | `commons/blurhash/` |
|
||||
| PlatformImage expect/actual | `commons/blurhash/PlatformImage.kt` → `.android.kt` / `.jvm.kt` |
|
||||
| Base64ImagePlatform | `commons/base64Image/` (JVM uses ImageIO) |
|
||||
| RichTextParser (URL → media type) | `commons/richtext/RichTextParser.kt` |
|
||||
| MediaContentModels | `commons/richtext/MediaContentModels.kt` |
|
||||
| UserAvatar (AsyncImage) | `commons/ui/components/UserAvatar.kt` |
|
||||
| GalleryParser | `commons/richtext/GalleryParser.kt` |
|
||||
| Blossom protocol events | `quartz/nipB7Blossom/` (kind 24242, 10063, URI, UploadResult) |
|
||||
| NIP-94 FileHeader | `quartz/nip94FileMetadata/` |
|
||||
| NIP-92 IMetaTag | `quartz/nip92IMeta/` |
|
||||
| NIP-68 PictureEvent | `quartz/nip68Picture/` |
|
||||
| NIP-71 VideoEvent/VideoMeta | `quartz/nip71Video/` |
|
||||
| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` |
|
||||
| `androidx.collection.LruCache` | Already in commons dependencies (KMP) |
|
||||
|
||||
**Android-only (needs extraction or desktop equivalent):**
|
||||
|
||||
| Component | File | Android Deps | Action |
|
||||
|-----------|------|-------------|--------|
|
||||
| BlossomUploader | `amethyst/service/uploads/blossom/BlossomUploader.kt` | ContentResolver, Uri, Context, MimeTypeMap | **Rewrite** HTTP core for desktop |
|
||||
| UploadOrchestrator | `amethyst/service/uploads/UploadOrchestrator.kt` | Context, Uri, R.string, Account, ServerType | **Rewrite** — too coupled for extraction |
|
||||
| MultiOrchestrator | `amethyst/service/uploads/MultiOrchestrator.kt` | Context | Defer |
|
||||
| MediaUploadResult | `amethyst/service/uploads/MediaUploadResult.kt` | Hidden BlurhashWrapper dep | Fix dep chain first |
|
||||
| MediaCompressor | `amethyst/service/uploads/MediaCompressor.kt` | Context, Bitmap, Uri, Compressor | expect/actual |
|
||||
| BlurhashMetadataCalculator | `amethyst/service/uploads/BlurhashMetadataCalculator.kt` | Context, BitmapFactory, MediaMetadataRetriever | expect/actual |
|
||||
| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt` | LruCache (Android), IRoleBasedHttpClientBuilder | Replace LruCache with `androidx.collection.LruCache` (KMP) |
|
||||
| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt` | None | Move (safe) |
|
||||
| ImageLoaderSetup | `amethyst/service/images/ImageLoaderSetup.kt` | Android SingletonImageLoader, GIF decoder (API 28+) | Desktop equivalent |
|
||||
| ImageCacheFactory | `amethyst/service/images/ImageCacheFactory.kt` | Context for safeCacheDir | Desktop paths |
|
||||
| BlossomFetcher | `amethyst/service/images/BlossomFetcher.kt` | Depends on BlossomServerResolver (Android LruCache) | Abstract resolver interface first |
|
||||
| BlurHashFetcher | `amethyst/service/images/BlurHashFetcher.kt` | toAndroidBitmap() | JVM: toBufferedImage() |
|
||||
| Base64Fetcher | `amethyst/service/images/Base64Fetcher.kt` | toBitmap() | JVM: toBufferedImage() |
|
||||
| ZoomableContentView | `amethyst/ui/components/ZoomableContentView.kt` | Android zoom lib | Desktop equivalent |
|
||||
| ImageGallery | `amethyst/ui/components/ImageGallery.kt` | Pure Compose | Extract |
|
||||
| MediaAspectRatioCache | `amethyst/model/MediaAspectRatioCache.kt` | Android LruCache | `androidx.collection.LruCache` (KMP) |
|
||||
|
||||
### Dependency Changes
|
||||
|
||||
**desktopApp/build.gradle.kts — add:**
|
||||
```kotlin
|
||||
// Image loading (Coil3)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.okhttp)
|
||||
implementation(libs.coil.svg)
|
||||
// NOTE: coil-gif is Android-only, skip it. Use Skia Codec instead.
|
||||
|
||||
// Coroutines — required for Coil3 Main dispatcher on JVM desktop
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
|
||||
// Video playback (VLCJ)
|
||||
implementation("uk.co.caprica:vlcj:4.8.3")
|
||||
|
||||
// EXIF stripping (lossless)
|
||||
implementation("org.apache.commons:commons-imaging:1.0.0-alpha5")
|
||||
```
|
||||
|
||||
**gradle/libs.versions.toml — add:**
|
||||
```toml
|
||||
vlcj = "4.8.3"
|
||||
commons-imaging = "1.0.0-alpha5"
|
||||
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
```
|
||||
|
||||
### Research Insight: Coil3 JVM Desktop Configuration
|
||||
|
||||
```kotlin
|
||||
// Critical: PlatformContext.INSTANCE (not Android Context)
|
||||
// Critical: Set memory cache explicitly (Coil3 hard-codes 512MB total on non-Android)
|
||||
// Critical: Set disk cache to OS-appropriate persistent path (default is temp dir)
|
||||
|
||||
fun createDesktopImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(PlatformContext.INSTANCE)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizeBytes(calculateDesktopMemoryCacheSize())
|
||||
.strongReferencesEnabled(true)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(getDesktopCacheDir().resolve("amethyst/image_cache").toOkioPath())
|
||||
.maxSizeBytes(512L * 1024 * 1024) // 512 MB
|
||||
.build()
|
||||
}
|
||||
.precision(Precision.INEXACT)
|
||||
.crossfade(true)
|
||||
.components {
|
||||
add(SvgDecoder.Factory()) // Works on JVM via Skia SVG
|
||||
// add(SkiaGifDecoder.Factory()) // Custom: first-frame GIF via Codec
|
||||
// add(BlossomFetcher.Factory(...))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
fun calculateDesktopMemoryCacheSize(): Long {
|
||||
val maxMemory = Runtime.getRuntime().maxMemory()
|
||||
return (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024)
|
||||
}
|
||||
|
||||
fun getDesktopCacheDir(): File {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
return when {
|
||||
"mac" in os -> File(System.getProperty("user.home"), "Library/Caches")
|
||||
"win" in os -> File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home"))
|
||||
else -> File(System.getenv("XDG_CACHE_HOME") ?: "${System.getProperty("user.home")}/.cache")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insight: Skia GIF Decoding (replaces coil-gif)
|
||||
|
||||
```kotlin
|
||||
// org.jetbrains.skia.Codec provides frame-by-frame GIF decoding
|
||||
// Skia supports: PNG, JPEG, WebP (static), BMP, ICO, WBMP
|
||||
// GIF: first frame via Image.makeFromEncoded, full animation via Codec
|
||||
// Animated WebP: first frame only (same Codec approach for animation)
|
||||
// HEIC: NOT supported on JVM
|
||||
|
||||
class SkiaGifDecoder(private val source: ImageSource) : Decoder {
|
||||
override suspend fun decode(): DecodeResult {
|
||||
val bytes = source.source().use { it.readByteArray() }
|
||||
val data = org.jetbrains.skia.Data.makeFromBytes(bytes)
|
||||
val codec = org.jetbrains.skia.Codec.makeFromData(data)
|
||||
val bitmap = org.jetbrains.skia.Bitmap()
|
||||
bitmap.allocN32Pixels(codec.width, codec.height)
|
||||
codec.readPixels(bitmap, 0) // first frame
|
||||
bitmap.setImmutable()
|
||||
return DecodeResult(image = bitmap.asImage(), isSampled = false)
|
||||
}
|
||||
class Factory : Decoder.Factory { /* check mimeType == "image/gif" */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insight: VLCJ DirectRendering Pattern
|
||||
|
||||
```kotlin
|
||||
// NO SwingPanel — renders directly to Skia Bitmap → Compose Image
|
||||
val callbackVideoSurface = CallbackVideoSurface(
|
||||
object : BufferFormatCallback {
|
||||
override fun getBufferFormat(w: Int, h: Int): BufferFormat {
|
||||
info = ImageInfo.makeN32(w, h, ColorAlphaType.OPAQUE)
|
||||
return RV32BufferFormat(w, h)
|
||||
}
|
||||
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {
|
||||
byteArray = ByteArray(buffers[0].limit())
|
||||
}
|
||||
},
|
||||
object : RenderCallback {
|
||||
override fun display(mp: MediaPlayer, bufs: Array<out ByteBuffer>, fmt: BufferFormat?) {
|
||||
bufs[0].get(byteArray); bufs[0].rewind()
|
||||
val bmp = Bitmap(); bmp.allocPixels(info!!); bmp.installPixels(byteArray)
|
||||
imageBitmap = bmp.asComposeImageBitmap() // triggers recomposition
|
||||
}
|
||||
},
|
||||
true, VideoSurfaceAdapters.getVideoSurfaceAdapter()
|
||||
)
|
||||
// Then display via: Image(bitmap = imageBitmap, ...)
|
||||
```
|
||||
|
||||
**Performance:** 2 frame copies per display frame. ~8MB/frame at 1080p. Adequate for 1-2 players, CPU-intensive for more. New ByteArray per frame required (reusing crashes).
|
||||
|
||||
### Research Insight: Compose Desktop Drag-and-Drop
|
||||
|
||||
```kotlin
|
||||
// Modern API (1.8+): Modifier.dragAndDropTarget
|
||||
// Old onExternalDrag was REMOVED in 1.8
|
||||
// awtTransferable + DataFlavor.javaFileListFlavor for files
|
||||
// text/uri-list fallback needed for Linux + browser drops
|
||||
// Cannot inspect file types during drag hover — filter in onDrop
|
||||
|
||||
val dropTarget = remember {
|
||||
object : DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
val transferable = event.awtTransferable ?: return false
|
||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
||||
onFilesDropped(files)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Clipboard:** Compose `ClipboardManager` is text-only. Use AWT `Toolkit.getDefaultToolkit().systemClipboard` with `DataFlavor.imageFlavor` for image paste, `DataFlavor.javaFileListFlavor` for file paste.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0: Spike — Coil3 + VLCJ Desktop Validation
|
||||
|
||||
**Goal:** Confirm Coil3 loads images on JVM desktop with disk cache. Confirm VLCJ DirectRendering works in Compose.
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 0.1 | Coil3 spike | `desktopApp/spike/CoilSpike.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with DiskCache (persistent path), MemoryCache (explicit size), load HTTP image via AsyncImage |
|
||||
| 0.2 | VLCJ spike | `desktopApp/spike/VlcjSpike.kt` | DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image`. Test mp4 URL. **NO SwingPanel** |
|
||||
| 0.3 | Validate SVG | spike | Test `coil-svg` + `SvgDecoder.Factory()` — confirmed KMP-compatible via Skia SVG |
|
||||
| 0.4 | Validate GIF (first frame) | spike | Test `Image.makeFromEncoded(bytes)` for static GIF. Optionally test `Codec` for frame count |
|
||||
| 0.5 | Add `kotlinx-coroutines-swing` | `desktopApp/build.gradle.kts` | Required for `Dispatchers.Main.immediate` |
|
||||
|
||||
**Gate:** If VLCJ DirectRendering drops frames badly or Coil3 disk cache fails, evaluate alternatives (Klarity for video, OkHttp cache interceptor for images).
|
||||
**Deliverable:** Spike branch with working video + image rendering.
|
||||
**Effort:** 1-2 days.
|
||||
|
||||
### Research Insight: Spike Simplification
|
||||
|
||||
The simplicity reviewer notes Phase 0 can be a 20-line test in `main()` — no need for separate spike files. Just add deps, create `ImageLoader`, call `AsyncImage` in the existing desktop window.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Image Display Foundation
|
||||
|
||||
**Goal:** Images render inline in desktop notes with blurhash previews.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 1.1 | Add Coil3 deps to desktopApp | `desktopApp/build.gradle.kts` | `coil-compose`, `coil-okhttp`, `coil-svg`, `kotlinx-coroutines-swing`. **NOT** `coil-gif` (Android-only) |
|
||||
| 1.2 | Create DesktopImageLoaderSetup | `desktopApp/service/images/DesktopImageLoaderSetup.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with explicit MemoryCache size (25% of JVM heap, max 512MB), DiskCache (OS-appropriate persistent path), OkHttp, SvgDecoder, custom fetchers. Package: `com.vitorpamplona.amethyst.desktop.service.images` |
|
||||
| 1.3 | Create DesktopImageCacheFactory | `desktopApp/service/images/DesktopImageCacheFactory.kt` | macOS: `~/Library/Caches/AmethystDesktop`, Linux: `$XDG_CACHE_HOME/AmethystDesktop`, Windows: `%LOCALAPPDATA%/AmethystDesktop/cache`. `maxSizeBytes(512MB)` |
|
||||
| 1.4 | Create desktop BlossomFetcher | `desktopApp/service/images/DesktopBlossomFetcher.kt` | **Desktop-only first** (don't extract to commons yet). Simplified version without `IRoleBasedHttpClientBuilder`. Uses single OkHttpClient for now. Resolves `blossom:` URIs via kind 10063 server list |
|
||||
| 1.5 | Create desktop BlurHashFetcher | `desktopApp/service/images/DesktopBlurHashFetcher.kt` | Uses `PlatformImage.toBufferedImage()` → `BitmapImage(toComposeImageBitmap())`. Implement stable `Keyer` to avoid recomposition flicker |
|
||||
| 1.6 | Create desktop Base64Fetcher | `desktopApp/service/images/DesktopBase64Fetcher.kt` | Uses existing `Base64ImagePlatform.jvm.kt` with ImageIO |
|
||||
| 1.7 | Create MediaAspectRatioCache | `desktopApp/model/MediaAspectRatioCache.kt` | Use `androidx.collection.LruCache(1000)` (already KMP dep). **NOT** `LinkedHashMap` (not thread-safe) |
|
||||
| 1.8 | Update NoteCard for inline images | `desktopApp/ui/note/NoteCard.kt` | Parse imeta tags from events. When URL matches image extension → `AsyncImage` with blurhash placeholder. Use `RichTextParser` media classification |
|
||||
| 1.9 | Initialize ImageLoader in Main.kt | `desktopApp/Main.kt` | Call `setSingletonImageLoaderFactory` at app startup. Use `@OptIn(DelicateCoilApi::class)` for eager init |
|
||||
| 1.10 | Add SkiaGifDecoder | `desktopApp/service/images/SkiaGifDecoder.kt` | Custom Coil3 `Decoder` using `org.jetbrains.skia.Codec` for GIF first-frame rendering |
|
||||
|
||||
**Deliverable:** Notes with images show blurhash placeholder → full image. `UserAvatar` also starts working.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` passes
|
||||
- [ ] Desktop app shows inline images in feed notes
|
||||
- [ ] Blurhash placeholders display while loading
|
||||
- [ ] SVG images render correctly (via SvgDecoder)
|
||||
- [ ] GIF shows first frame (static display is acceptable for MVP)
|
||||
- [ ] `UserAvatar` renders profile pictures
|
||||
- [ ] Disk cache persists across app restarts (not in temp dir)
|
||||
- [ ] Android build unbroken: `./gradlew :amethyst:compileDebugKotlin`
|
||||
|
||||
### Research Insight: Coil3 Custom Fetcher Gotchas
|
||||
|
||||
- Use `coil3.Uri` not `android.net.Uri`
|
||||
- `ConnectivityChecker` is a no-op on desktop (always returns "connected")
|
||||
- Custom fetchers **must** implement stable `Keyer` or cache key — otherwise recomposition triggers re-fetch ([#2551](https://github.com/coil-kt/coil/issues/2551))
|
||||
- `PlatformContext.INSTANCE` is an empty singleton on JVM — don't cast or use Android methods
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Desktop Blossom Upload Client
|
||||
|
||||
**Goal:** Upload media from desktop to Blossom servers.
|
||||
|
||||
**Approach:** Write a **desktop-only** upload client in `desktopApp/`. Don't extract to commons yet (simplicity-first). The Android upload path stays unchanged.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 2.1 | Create DesktopBlossomClient | `desktopApp/service/upload/DesktopBlossomClient.kt` | HTTP PUT `/upload`, `/mirror`, `/media`, DELETE. Takes `File` + metadata. Returns `BlossomUploadResult` (from quartz). Uses OkHttp. Package: `com.vitorpamplona.amethyst.desktop.service.upload` |
|
||||
| 2.2 | Create DesktopBlossomAuthHelper | `desktopApp/service/upload/DesktopBlossomAuthHelper.kt` | Creates kind 24242 auth events via quartz `BlossomAuthorizationEvent`, base64-encodes for `Authorization: Nostr <b64>` header. Include `t`, `x`, `expiration`, `server` tags per BUD-11 |
|
||||
| 2.3 | Create DesktopServerDiscovery | `desktopApp/service/upload/DesktopServerDiscovery.kt` | Queries kind 10063 from relays, resolves server list, caches with `androidx.collection.LruCache` |
|
||||
| 2.4 | Create DesktopMediaMetadata | `desktopApp/service/upload/DesktopMediaMetadata.kt` | `ImageIO.read(file)` for dimensions, `BlurHashEncoder` (from commons) for blurhash, file size + SHA-256 |
|
||||
| 2.5 | Create DesktopMediaCompressor | `desktopApp/service/upload/DesktopMediaCompressor.kt` | JPEG: `ImageWriteParam.compressionQuality`. EXIF strip: Apache Commons Imaging `ExifRewriter.removeExifMetadata()`. No re-encoding for EXIF removal |
|
||||
| 2.6 | Create DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Coordinate: strip EXIF → compute metadata → auth → upload. Accept `File` directly (not Uri). Blossom-only (no NIP-95/96) |
|
||||
| 2.7 | Create DesktopMediaUploadTracker | `desktopApp/service/upload/DesktopMediaUploadTracker.kt` | Simple `mutableStateOf(isUploading: Boolean)` for MVP (matches existing Android's 47-line tracker). StateFlow upgrade later |
|
||||
| 2.8 | BUD-06 preflight check | `desktopApp/service/upload/ServerHeadCache.kt` | HEAD `/upload` with `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers. Cache results |
|
||||
| 2.9 | Unit tests | `desktopApp/src/jvmTest/` | Mock OkHttp responses. Test upload, auth header format (base64 of kind 24242), SHA-256 computation, EXIF stripping |
|
||||
|
||||
**Deliverable:** `./gradlew :desktopApp:jvmTest` passes. Desktop can upload files to Blossom.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] BlossomClient uploads file to test server (integration test or mock)
|
||||
- [ ] Auth headers correctly signed (kind 24242 with `t=upload`, `x=<hash>`, `expiration`, `server`)
|
||||
- [ ] SHA-256 computed correctly for test files
|
||||
- [ ] EXIF stripped losslessly from JPEG test image
|
||||
- [ ] BUD-06 preflight HEAD works
|
||||
- [ ] Android build unbroken
|
||||
|
||||
### Research Insight: Blossom Protocol Details
|
||||
|
||||
**Upload flow per BUD-02:**
|
||||
1. Compute SHA-256 of exact file bytes
|
||||
2. Create kind 24242 event: `t=upload`, `x=<sha256>`, `expiration=+1hr`, `server=<domain>`
|
||||
3. Base64-encode (standard base64 works despite spec saying base64url)
|
||||
4. `PUT /upload` with `Authorization: Nostr <b64>`, `Content-Type`, `Content-Length`, `X-SHA-256`
|
||||
5. Response: `BlobDescriptor` JSON with `url`, `sha256`, `size`, `type`, `uploaded`
|
||||
|
||||
**`/upload` vs `/media` (BUD-05):**
|
||||
- `/upload` — server MUST NOT modify blob. Hash preserved.
|
||||
- `/media` — server MAY optimize (strip EXIF, compress). Returned hash differs. Primal uses this exclusively.
|
||||
- **Recommendation:** Use `/upload` first (simpler). Add `/media` support later for trusted servers.
|
||||
|
||||
**Auth token security (BUD-11):**
|
||||
- Always include `server` tag to prevent token replay across servers
|
||||
- Unscoped `delete` tokens can be replayed — always scope delete operations
|
||||
- Include `x` tag (blob hash) for upload/delete operations
|
||||
|
||||
### Research Insight: Race Conditions in Upload
|
||||
|
||||
**CRITICAL:** Upload scope cancellation on navigation. If user navigates away during upload, the coroutine scope gets cancelled. Mitigation: launch uploads in a `GlobalScope` or `applicationScope` that survives navigation, with a reference in the tracker.
|
||||
|
||||
**HIGH:** DnD during active upload. User drops new files while upload in progress. Mitigation: queue uploads, don't replace in-flight uploads.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Desktop Upload UX
|
||||
|
||||
**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 3.1 | Desktop file picker | `desktopApp/ui/media/DesktopFilePicker.kt` | `java.awt.FileDialog` (native look) or `JFileChooser` with media type filters. Multi-select support |
|
||||
| 3.2 | Drag-and-drop handler | `desktopApp/ui/media/DragDropHandler.kt` | `Modifier.dragAndDropTarget` (modern API, 1.8+). `event.awtTransferable` + `DataFlavor.javaFileListFlavor`. Include `text/uri-list` fallback for Linux. Visual drop zone indicator via `onEntered`/`onExited` callbacks |
|
||||
| 3.3 | Clipboard paste handler | `desktopApp/ui/media/ClipboardPasteHandler.kt` | AWT `Toolkit.getDefaultToolkit().systemClipboard`. Check `DataFlavor.imageFlavor` (→ save BufferedImage to temp file → upload) and `DataFlavor.javaFileListFlavor` (→ direct file upload) |
|
||||
| 3.4 | Upload dialog composable | `desktopApp/ui/media/UploadDialog.kt` | Progress bar per file, server selector (kind 10063 list), alt text input, cancel button |
|
||||
| 3.5 | Upload preview thumbnails | `desktopApp/ui/media/UploadPreview.kt` | Thumbnail generation from local file using ImageIO |
|
||||
| 3.6 | Wire upload to ComposeNoteDialog | `desktopApp/ui/ComposeNoteDialog.kt` | Add "Attach media" button. Connect to file picker + drag-drop. On upload complete → insert imeta tag + URL into note |
|
||||
| 3.7 | Media button in note composer | `desktopApp/ui/ComposeNoteDialog.kt` | IconButton (Attach) → opens file picker. Shows attached files with remove option |
|
||||
|
||||
**Deliverable:** Drop/paste/pick file → preview → upload → note publishes with embedded image.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] File picker opens, filters by media type, multi-select works
|
||||
- [ ] Drag file onto compose area shows drop zone highlight
|
||||
- [ ] Clipboard paste (Ctrl+V / Cmd+V) detects images and files
|
||||
- [ ] Upload progress shows per-file
|
||||
- [ ] Server selection from kind 10063 list
|
||||
- [ ] Alt text saved in imeta tag
|
||||
- [ ] Published note contains correct imeta tags
|
||||
- [ ] Cancel upload works mid-flight
|
||||
|
||||
### Research Insight: DnD Platform Quirks
|
||||
|
||||
| Platform | Behavior | Note |
|
||||
|----------|----------|------|
|
||||
| macOS Finder | `javaFileListFlavor` works | Aliases resolve to alias file (use `canonicalFile`) |
|
||||
| Windows Explorer | `javaFileListFlavor` works | Stable |
|
||||
| Linux Nautilus | `javaFileListFlavor` works | Some WMs only provide `text/uri-list` |
|
||||
| Browser drag | Usually `text/uri-list` | Not `javaFileListFlavor` — handle separately |
|
||||
| **Cannot inspect file types during drag hover** | Only know flavor (files vs text), not extensions | Filter in `onDrop`, show generic "drop files here" during hover |
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Video Playback (VLCJ DirectRendering)
|
||||
|
||||
**Goal:** Videos play inline in desktop notes.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 4.1 | Add VLCJ deps | `desktopApp/build.gradle.kts` | `uk.co.caprica:vlcj:4.8.3`. **No vlc-setup plugin** — require system VLC for now (arm64 macOS not confirmed for plugin) |
|
||||
| 4.2 | DesktopVideoPlayer composable | `desktopApp/ui/media/DesktopVideoPlayer.kt` | DirectRendering: `CallbackVideoSurface` → `RV32BufferFormat` → `ByteBuffer.get(byteArray)` → `Skia Bitmap.installPixels()` → `asComposeImageBitmap()` → Compose `Image`. Lazy init player |
|
||||
| 4.3 | Video controls overlay | `desktopApp/ui/media/VideoControls.kt` | Play/pause, seek bar, volume slider, fullscreen toggle, time display. Auto-hide after 2s. Compose overlay on top of Compose Image (no z-order issues since no SwingPanel) |
|
||||
| 4.4 | Video in note rendering | `desktopApp/ui/note/NoteCard.kt` | When URL matches video extension → show thumbnail + play button. On click → load VLCJ player |
|
||||
| 4.5 | Video upload support | `desktopApp/ui/media/UploadDialog.kt` | Accept video files in upload flow. No transcoding — upload as-is |
|
||||
| 4.6 | Video thumbnail generation | `desktopApp/service/media/VideoThumbnailGenerator.kt` | Use VLCJ `snapshots().get(320, 0)` on a dedicated player. Play → seek to 2s → pause → snapshot → stop |
|
||||
| 4.7 | VLCJ player pool | `desktopApp/service/media/VlcjPlayerPool.kt` | Single `MediaPlayerFactory`, pool of 2-3 reusable `EmbeddedMediaPlayer` instances. Reuse players (`media().play(newMrl)`) instead of create/destroy. Strong references to prevent GC crash |
|
||||
|
||||
**Deliverable:** Video posts play inline. Controls work. Resource-efficient.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] mp4, webm URLs play inline (m3u8 if VLC supports)
|
||||
- [ ] Play/pause, seek, volume controls functional
|
||||
- [ ] Fullscreen toggle works
|
||||
- [ ] Video pauses when scrolled out of view
|
||||
- [ ] Player instances pooled (max 3 active)
|
||||
- [ ] Graceful fallback if VLC init fails (show URL link)
|
||||
- [ ] VLC not installed → show "Install VLC" prompt with download link
|
||||
|
||||
### Research Insight: VLCJ Lifecycle Critical Rules
|
||||
|
||||
1. **Never let player instances get garbage collected** — native callbacks crash JVM if Java object is collected
|
||||
2. **Keep strong references** to `MediaPlayerFactory`, `EmbeddedMediaPlayer`, and video surfaces
|
||||
3. **Reuse players** — one factory, pool of players. Change media with `media().play(newMrl)`
|
||||
4. **Release order:** player first, then factory
|
||||
5. **macOS: only CallbackVideoSurface works** — no heavyweight AWT components since Java 7
|
||||
6. **Audio-only:** Use `MediaPlayerFactory("--no-video")` for audio tracks
|
||||
|
||||
### Research Insight: Race Conditions in Video
|
||||
|
||||
**CRITICAL:** VLCJ use-after-release segfault. If `DisposableEffect.onDispose` releases player while a `RenderCallback.display()` is executing on the VLC thread → native crash. Mitigation: state machine per player slot. Set `RELEASING` state, wait for in-flight render to complete, then release.
|
||||
|
||||
**CRITICAL:** Rapid navigation between notes with video. User scrolls fast → player allocated → scrolled away before `mediaPlayerReady` event → player released during initialization. Mitigation: debounce player allocation (300ms visibility threshold before allocating).
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Lightbox & Gallery
|
||||
|
||||
**Goal:** Full-screen media viewing with zoom and gallery navigation.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 5.1 | Lightbox overlay | `desktopApp/ui/media/LightboxOverlay.kt` | Full-window overlay with semi-transparent backdrop. Click outside to close |
|
||||
| 5.2 | Zoom + pan | `desktopApp/ui/media/ZoomableImage.kt` | Mouse wheel zoom, click-drag pan. `graphicsLayer` with `scaleX/Y` + `translationX/Y`. Double-click to reset |
|
||||
| 5.3 | Gallery carousel | `desktopApp/ui/media/GalleryCarousel.kt` | Multi-image navigation with left/right buttons and indicator dots |
|
||||
| 5.4 | Save to disk | `desktopApp/ui/media/SaveMediaAction.kt` | Button → `JFileChooser` save dialog. Download URL → write to chosen path |
|
||||
| 5.5 | Keyboard shortcuts | `desktopApp/ui/media/LightboxOverlay.kt` | Esc=close, Left/Right=navigate, +/-=zoom, Ctrl+S=save, Space=play/pause (video) |
|
||||
| 5.6 | Extract ImageGallery layout logic | `amethyst/ui/components/ImageGallery.kt` → `commons/` | The 1-5+ image grid layout is pure Compose — extract to commons for reuse |
|
||||
|
||||
**Deliverable:** Click image → fullscreen lightbox with zoom. Multi-image carousel. Keyboard navigation.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Click any inline image opens lightbox
|
||||
- [ ] Mouse wheel zooms in/out smoothly
|
||||
- [ ] Click-drag pans when zoomed
|
||||
- [ ] Arrow keys navigate gallery
|
||||
- [ ] Esc closes lightbox
|
||||
- [ ] Save downloads file to disk
|
||||
- [ ] Video plays in lightbox with controls
|
||||
|
||||
### Research Insight: Race Condition — Lightbox Double-Click
|
||||
|
||||
**MEDIUM:** User double-clicks an image. First click opens lightbox, second click registers as "click outside" → closes immediately. Mitigation: 200ms debounce on lightbox open/close transitions.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Encrypted Media (NIP-17 DM Files)
|
||||
|
||||
**Goal:** Send and receive encrypted files in DMs.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 6.1 | Verify encryption is shared | `quartz/nip17Dm/files/` | `ChatMessageEncryptedFileHeaderEvent` (kind 15) already in commonMain with AESGCM cipher from quartz utils. Should work on desktop as-is |
|
||||
| 6.2 | Encrypted upload flow | `desktopApp/ui/chat/` | Pick file → encrypt with NIP-44 → Blossom upload → create ChatMessageEncryptedFileHeaderEvent |
|
||||
| 6.3 | Encrypted download + display | `desktopApp/ui/chat/` | Receive encrypted file event → download from Blossom → decrypt → display/save |
|
||||
| 6.4 | Chat file upload UI | `desktopApp/ui/chat/ChatFileUploader.kt` | Similar to Phase 3 upload dialog but in DM context. Show encryption indicator |
|
||||
|
||||
**Deliverable:** Desktop DM users can send/receive encrypted images and files.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Send image in DM from desktop, visible on Android
|
||||
- [ ] Receive encrypted image from Android, displays on desktop
|
||||
- [ ] Non-participants cannot view encrypted media
|
||||
- [ ] Upload progress shown in chat compose
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Profile Gallery (NIP-68)
|
||||
|
||||
**Goal:** View and create picture-first posts (kind 20). Profile gallery tab.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 7.1 | Picture event display | `desktopApp/ui/note/PictureDisplay.kt` | Kind 20 renderer. Image-first layout with title + description below. Uses imeta tags for multi-image |
|
||||
| 7.2 | Profile gallery tab | `desktopApp/ui/profile/GalleryTab.kt` | Grid layout of user's picture posts. Lazy grid with thumbnails |
|
||||
| 7.3 | Picture post composer | `desktopApp/ui/media/PicturePostComposer.kt` | Create kind 20 events. Multi-image upload + imeta + title + description |
|
||||
| 7.4 | Gallery entry event support | `desktopApp/ui/profile/GalleryTab.kt` | Read `ProfileGalleryEntryEvent` (kind 1163) for curated galleries |
|
||||
|
||||
**Deliverable:** Profile shows gallery tab with image grid. Users can create picture posts.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Profile screen shows Gallery tab
|
||||
- [ ] Gallery grid loads thumbnails with blurhash
|
||||
- [ ] Click thumbnail opens lightbox
|
||||
- [ ] Can create kind 20 picture post from desktop
|
||||
- [ ] Picture post visible on Android Amethyst
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Media Server Management
|
||||
|
||||
**Goal:** UI for managing Blossom server list (kind 10063).
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 8.1 | Server list settings screen | `desktopApp/ui/settings/MediaServerSettings.kt` | View/add/remove servers. Drag to reorder |
|
||||
| 8.2 | Server status checking | `desktopApp/service/media/ServerHealthCheck.kt` | HEAD request to verify availability. Show green/red indicator |
|
||||
| 8.3 | Default server selection | settings UI | Mark preferred upload server. Persist in Preferences |
|
||||
| 8.4 | Publish kind 10063 | Uses quartz `BlossomServersEvent` | Update on relays when user modifies list |
|
||||
|
||||
**Deliverable:** Settings page to manage Blossom servers.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Server list loads from kind 10063 event
|
||||
- [ ] Add/remove servers updates list
|
||||
- [ ] Health check shows server status
|
||||
- [ ] Changes published to relays
|
||||
- [ ] Upload dialog reflects server list
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Audio Playback
|
||||
|
||||
**Goal:** Play audio tracks (MP3, OGG, FLAC, WAV) inline in notes.
|
||||
|
||||
| # | Task | Module | Details |
|
||||
|---|------|--------|---------|
|
||||
| 9.1 | Audio player composable | `desktopApp/ui/media/AudioPlayer.kt` | VLCJ with `MediaPlayerFactory("--no-video")`. Play/pause + progress bar + time. No video surface needed |
|
||||
| 9.2 | Audio in note rendering | `desktopApp/ui/note/NoteCard.kt` | URL matches audio extension → show inline audio player |
|
||||
| 9.3 | Waveform visualization (optional) | `desktopApp/ui/media/AudioWaveform.kt` | Simple amplitude bars. Low priority — skip if VLCJ doesn't expose PCM |
|
||||
|
||||
**Deliverable:** Audio files play inline in notes.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] MP3, OGG, FLAC URLs show audio player
|
||||
- [ ] Play/pause and seek work
|
||||
- [ ] Multiple audio players on screen don't conflict
|
||||
|
||||
### Phase Dependency Graph
|
||||
|
||||
```
|
||||
Phase 0 (Spike) ──→ Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox)
|
||||
│
|
||||
Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted)
|
||||
│
|
||||
├──→ Phase 7 (Gallery)
|
||||
│
|
||||
└──→ Phase 8 (Server Mgmt)
|
||||
|
||||
Phase 4 (Video) ────────────→ Phase 9 (Audio)
|
||||
|
||||
Independent: Phase 0 first. Then Phase 1, 2, 4 can run in parallel.
|
||||
Ship Phases 0-3 as first PR. Phases 4-9 are incremental follow-ups.
|
||||
```
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
- Image display: NoteCard → RichTextParser → MediaContentModels → Coil3 ImageLoader → custom Fetchers → OkHttp → Blossom servers
|
||||
- Upload: ComposeNoteDialog → DesktopFilePicker/DragDrop/Clipboard → DesktopMediaCompressor → DesktopBlossomClient → OkHttp → Blossom server → imeta tag → relay broadcast
|
||||
- Auth chain: DesktopUploadOrchestrator → DesktopBlossomAuthHelper → quartz BlossomAuthorizationEvent → Account.signer → kind 24242 → base64 header
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
| Error Source | Handling |
|
||||
|-------------|----------|
|
||||
| Coil3 load failure | Show blurhash if available, else placeholder icon. Log error |
|
||||
| Blossom upload network error | Retry with exponential backoff (3 attempts). Show error in upload dialog |
|
||||
| VLCJ init failure (VLC not installed) | Show "Install VLC" prompt with download link. Fall back to URL link |
|
||||
| SHA-256 mismatch after upload | Re-upload. Warn user if persistent |
|
||||
| Server unreachable (BUD-06 preflight) | Skip server, try next in list |
|
||||
| Encrypted media decrypt failure | Show "Unable to decrypt" placeholder |
|
||||
| EXIF strip failure | Upload anyway with warning (privacy risk) |
|
||||
|
||||
### Race Condition Mitigations (from review)
|
||||
|
||||
| Severity | Issue | Mitigation |
|
||||
|----------|-------|------------|
|
||||
| **CRITICAL** | LinkedHashMap concurrent corruption | Use `androidx.collection.LruCache` (thread-safe) or `ConcurrentHashMap` |
|
||||
| **CRITICAL** | VLCJ use-after-release segfault | State machine per player: `IDLE`→`LOADING`→`PLAYING`→`RELEASING`. Guard `RenderCallback.display()` with state check |
|
||||
| **CRITICAL** | Upload scope cancelled on navigation | Launch uploads in application-scoped coroutine (survives navigation) |
|
||||
| HIGH | Stale blurhash/image flash in lazy list | Stable `Keyer` for Coil3 custom fetchers |
|
||||
| HIGH | DnD during active upload | Queue uploads, don't replace in-flight |
|
||||
| HIGH | Non-atomic progress + state update | Use `MutableStateFlow<UploadState>` (single source of truth, atomic update) |
|
||||
| HIGH | Pause-after-dispose (VLCJ) | Check player state before calling `controls().pause()` |
|
||||
| MEDIUM | Stale server cache | TTL on server discovery cache (5 min) |
|
||||
| MEDIUM | Lightbox double-click | 200ms debounce on open/close |
|
||||
| MEDIUM | Gallery rapid navigation | Debounce image load requests |
|
||||
| MEDIUM | StateFlow progress frequency | Throttle upload progress updates to 100ms intervals |
|
||||
| LOW | Clipboard + DnD concurrent | Serialize input handling (queue) |
|
||||
| LOW | Video control timer overlap | Single timer job for auto-hide |
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Upload in progress + user navigates away | Application-scoped coroutine. Upload continues in background. Show notification on completion |
|
||||
| VLCJ player leak | `DisposableEffect` with state machine release. Player pool enforces max count. Strong references prevent GC crash |
|
||||
| Coil disk cache corruption | Coil3 handles internally. Worst case: re-download |
|
||||
| Partial upload (network cut) | SHA-256 pre-computed. Server rejects incomplete uploads. Client retries |
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
| Interface | Android | Desktop | Same? |
|
||||
|-----------|---------|---------|-------|
|
||||
| BlossomClient | Android-specific | Desktop-specific | **Different** (for now — extract to commons later) |
|
||||
| UploadOrchestrator | Android-specific | Desktop-specific | **Different** (for now) |
|
||||
| ImageLoader fetchers | Android-specific | Desktop-specific | **Different** (for now — same Coil3 API though) |
|
||||
| VideoPlayer | ExoPlayer | VLCJ DirectRendering | Different |
|
||||
| FilePicker | ActivityResultContracts | JFileChooser / FileDialog | Different |
|
||||
| MediaCompressor | Zelory + MediaCodec | ImageIO + Commons Imaging | Different |
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
1. **Upload round-trip:** Desktop uploads image → published note with imeta → other client sees image via Blossom URL
|
||||
2. **Cross-platform encrypted DM:** Android sends encrypted image → Desktop receives, decrypts, displays
|
||||
3. **Blossom fallback:** Primary server down → resolver tries secondary server from kind 10063
|
||||
4. **Gallery consistency:** Create kind 20 on desktop → visible in Android profile gallery
|
||||
5. **Video lifecycle:** Scroll feed with 5 video notes → only 2-3 VLCJ players active → no OOM
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
| Approach | Why Rejected |
|
||||
|----------|-------------|
|
||||
| NIP-96 + Blossom | NIP-96 deprecated. Double protocol = double maintenance (see brainstorm) |
|
||||
| JavaFX MediaView for video | Adds JavaFX runtime (~50MB), conflicts with Compose Desktop rendering |
|
||||
| SwingPanel for VLCJ | Confirmed flickering + always-on-top z-order issues. DirectRendering avoids entirely |
|
||||
| Extract to commons immediately | Over-engineering for 0 desktop users. Ship desktop-only first, extract when needed |
|
||||
| FFmpeg JNI for video | Complex native binding. VLCJ wraps VLC which bundles FFmpeg internally |
|
||||
| Ktor instead of OkHttp | OkHttp already used everywhere. Both are JVM. No benefit to switching |
|
||||
| `metadata-extractor` for EXIF strip | **Read-only library**. Only reads EXIF, cannot remove it |
|
||||
| `LinkedHashMap` for caches | **Not thread-safe** in access-order mode. `get()` mutates internal state |
|
||||
| Klarity (FFmpeg-based) | Interesting alternative (65 stars, Feb 2026 update), but small community. Worth prototyping as VLCJ backup |
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| VLCJ DirectRendering CPU overhead at 1080p | Medium | Medium | ~8MB/frame copy. Adequate for 1-2 players. Pool limits exposure |
|
||||
| VLC not installed on user system | High | High | Show clear "Install VLC" prompt. Link to VLC download. Future: bundle VLC |
|
||||
| vlc-setup plugin no arm64 macOS | Confirmed | Medium | Don't use plugin for now. Require system VLC. Revisit when plugin matures |
|
||||
| Coil3 JVM edge cases | Low | Medium | Coil 3.4.0 is mature. Explicit cache sizing avoids defaults. SvgDecoder confirmed working |
|
||||
| Commons extraction breaks Android | N/A (deferred) | N/A | Desktop-only approach for Phases 0-3 eliminates this risk |
|
||||
| Apache Commons Imaging alpha status | Medium | Low | Only using EXIF strip (well-tested path). Fallback: skip EXIF strip for MVP |
|
||||
| Scope creep across 9 phases | High | High | Ship Phases 0-3 as first PR. Later phases are incremental |
|
||||
| Compose DnD experimental API changes | Medium | Low | Pin Compose version. AWT `DropTarget` fallback available |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Images display inline in desktop feed notes with blurhash placeholders
|
||||
- [ ] Upload works via file picker, drag-drop, and clipboard paste
|
||||
- [ ] Videos play inline with controls (play/pause, seek, volume)
|
||||
- [ ] Lightbox opens on image click with zoom and gallery navigation
|
||||
- [ ] Encrypted media works in DMs (send + receive)
|
||||
- [ ] Profile gallery shows picture posts
|
||||
- [ ] Blossom server management UI in settings
|
||||
- [ ] Audio plays inline in notes
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] Image load time < 2s for typical photos on broadband
|
||||
- [ ] Upload shows progress in real-time
|
||||
- [ ] Max 3 active video players (prevent OOM)
|
||||
- [ ] Disk cache respects 512MB limit
|
||||
- [ ] EXIF stripped before upload (privacy)
|
||||
- [ ] Android build never broken (desktop-only approach)
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` passes
|
||||
- [ ] `./gradlew :desktopApp:jvmTest` passes (upload unit tests)
|
||||
- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (no Android breakage)
|
||||
- [ ] `./gradlew spotlessApply` passes
|
||||
- [ ] Manual test: full upload → display round-trip on desktop
|
||||
|
||||
## Key Files (New or Modified)
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Phase |
|
||||
|------|-------|
|
||||
| `desktopApp/.../service/images/DesktopImageLoaderSetup.kt` | 1 |
|
||||
| `desktopApp/.../service/images/DesktopImageCacheFactory.kt` | 1 |
|
||||
| `desktopApp/.../service/images/DesktopBlossomFetcher.kt` | 1 |
|
||||
| `desktopApp/.../service/images/DesktopBlurHashFetcher.kt` | 1 |
|
||||
| `desktopApp/.../service/images/DesktopBase64Fetcher.kt` | 1 |
|
||||
| `desktopApp/.../service/images/SkiaGifDecoder.kt` | 1 |
|
||||
| `desktopApp/.../model/MediaAspectRatioCache.kt` | 1 |
|
||||
| `desktopApp/.../service/upload/DesktopBlossomClient.kt` | 2 |
|
||||
| `desktopApp/.../service/upload/DesktopBlossomAuthHelper.kt` | 2 |
|
||||
| `desktopApp/.../service/upload/DesktopServerDiscovery.kt` | 2 |
|
||||
| `desktopApp/.../service/upload/DesktopMediaMetadata.kt` | 2 |
|
||||
| `desktopApp/.../service/upload/DesktopMediaCompressor.kt` | 2 |
|
||||
| `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` | 2 |
|
||||
| `desktopApp/.../service/upload/DesktopMediaUploadTracker.kt` | 2 |
|
||||
| `desktopApp/.../ui/media/DesktopFilePicker.kt` | 3 |
|
||||
| `desktopApp/.../ui/media/DragDropHandler.kt` | 3 |
|
||||
| `desktopApp/.../ui/media/ClipboardPasteHandler.kt` | 3 |
|
||||
| `desktopApp/.../ui/media/UploadDialog.kt` | 3 |
|
||||
| `desktopApp/.../ui/media/UploadPreview.kt` | 3 |
|
||||
| `desktopApp/.../ui/media/DesktopVideoPlayer.kt` | 4 |
|
||||
| `desktopApp/.../ui/media/VideoControls.kt` | 4 |
|
||||
| `desktopApp/.../service/media/VlcjPlayerPool.kt` | 4 |
|
||||
| `desktopApp/.../service/media/VideoThumbnailGenerator.kt` | 4 |
|
||||
| `desktopApp/.../ui/media/LightboxOverlay.kt` | 5 |
|
||||
| `desktopApp/.../ui/media/ZoomableImage.kt` | 5 |
|
||||
| `desktopApp/.../ui/media/GalleryCarousel.kt` | 5 |
|
||||
| `desktopApp/.../ui/media/AudioPlayer.kt` | 9 |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Phase | Change |
|
||||
|------|-------|--------|
|
||||
| `desktopApp/build.gradle.kts` | 0,1,4 | Add Coil3, kotlinx-coroutines-swing, VLCJ, Commons Imaging deps |
|
||||
| `gradle/libs.versions.toml` | 0,1,4 | Add new version entries |
|
||||
| `desktopApp/ui/note/NoteCard.kt` | 1,4,9 | Add inline media rendering |
|
||||
| `desktopApp/ui/ComposeNoteDialog.kt` | 3 | Add media attach button + upload flow |
|
||||
| `desktopApp/Main.kt` | 1 | Initialize ImageLoader |
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm document:** [docs/brainstorms/2026-03-16-desktop-media-brainstorm.md](docs/brainstorms/2026-03-16-desktop-media-brainstorm.md)
|
||||
- Key decisions carried forward: Blossom-only, Coil3 for images, VLCJ for video, desktop-only-first approach
|
||||
|
||||
### Internal References
|
||||
|
||||
- Existing shared UI analysis: `docs/shared-ui-analysis.md`
|
||||
- PlatformImage expect/actual pattern: `commons/blurhash/PlatformImage.kt`
|
||||
- Android ImageLoader setup: `amethyst/service/images/ImageLoaderSetup.kt:25-91`
|
||||
- Android BlossomUploader: `amethyst/service/uploads/blossom/BlossomUploader.kt`
|
||||
- NoteCard (current text-only): `desktopApp/ui/note/NoteCard.kt:128-132`
|
||||
- ComposeNoteDialog (current text-only): `desktopApp/ui/ComposeNoteDialog.kt`
|
||||
- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md`
|
||||
|
||||
### External References
|
||||
|
||||
- Blossom protocol spec: https://github.com/hzrd149/blossom
|
||||
- Coil3 Compose Multiplatform: https://coil-kt.github.io/coil/compose/
|
||||
- Coil3 GIF on JVM limitation: https://github.com/coil-kt/coil/issues/2347
|
||||
- Coil3 SVG on desktop: https://github.com/coil-kt/coil/issues/2330
|
||||
- Coil3 Main dispatcher: https://github.com/coil-kt/coil/issues/2009
|
||||
- VLCJ documentation: https://github.com/caprica/vlcj
|
||||
- VLCJ GC tutorial: https://capricasoftware.co.uk/tutorials/vlcj/4/garbage-collection
|
||||
- ComposeVideoPlayer (DirectRendering): https://github.com/rjuszczyk/ComposeVideoPlayer
|
||||
- Klarity (FFmpeg alternative): https://github.com/numq/Klarity
|
||||
- vlc-setup Gradle plugin: https://github.com/nickolay-mahozad/vlc-setup
|
||||
- Apache Commons Imaging: https://commons.apache.org/proper/commons-imaging/
|
||||
- Compose Desktop DnD docs: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-drag-drop.html
|
||||
- Skiko Codec API: https://jetbrains.github.io/skiko/skiko/org.jetbrains.skia/-codec/index.html
|
||||
- HEIC not supported on JVM: https://github.com/JetBrains/skiko/issues/942
|
||||
|
||||
## Answered Questions (from original plan)
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|----------|--------|
|
||||
| 1 | Does `coil3-gif` work on JVM desktop? | **No.** Android-only (`AnimatedImageDecoder` requires API 28+). Use `org.jetbrains.skia.Codec` for GIF decoding |
|
||||
| 2 | Does `vlc-setup` support arm64 macOS? | **Unconfirmed.** Only Intel Mac (High Sierra) tested. macOS marked "experimental". Require system VLC for now |
|
||||
| 3 | Does `awtTransferable` deliver files on macOS? | **Yes.** `DataFlavor.javaFileListFlavor` works with Finder on JDK 17+. Include `text/uri-list` fallback for Linux |
|
||||
| 4 | Is `LinkedHashMap.removeEldestEntry` sufficient? | **No.** Not thread-safe in access-order mode. Use `androidx.collection.LruCache` (already KMP dep) or `ConcurrentHashMap` |
|
||||
| 5 | Is Coil3 `SvgDecoder` KMP? | **Yes.** Works on JVM via `org.jetbrains.skia.svg.SVGDOM`. Add `SvgDecoder.Factory()` to ImageLoader components |
|
||||
| 6 | Is `UserAvatar` rendering on desktop? | **Likely failing silently** — no `SingletonImageLoader` configured. Will work after Phase 1 ImageLoader init |
|
||||
|
||||
## Remaining Unanswered Questions
|
||||
|
||||
1. Does VLCJ DirectRendering achieve acceptable frame rate at 1080p on Apple Silicon?
|
||||
2. Can `org.jetbrains.skia.Codec` handle all animated WebP variants reliably on JVM?
|
||||
3. Is VLC 3.0.21 universal binary reliable via JNA on arm64 macOS with JDK 17+?
|
||||
4. Is there a GPU-accelerated path for VLCJ → Skia Bitmap transfer (avoid CPU copy)?
|
||||
5. Does `Modifier.dragAndDropTarget` work on Linux Wayland in Compose 1.10.x?
|
||||
6. Can Klarity handle streaming URLs (HLS, DASH) as VLCJ alternative?
|
||||
7. Does Coil3 `@ExperimentalCoilApi` `NetworkFetcher` constructor stay stable across versions?
|
||||
@@ -0,0 +1,800 @@
|
||||
---
|
||||
title: "feat: Desktop DM Encrypted Media (NIP-17)"
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-03-18
|
||||
deepened: 2026-03-18
|
||||
origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Desktop DM Encrypted Media (NIP-17)
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-18
|
||||
**Sections enhanced:** 5 phases + security + performance + edge cases
|
||||
**Research sources:** Source code analysis of all 9 key files, Android reference implementation, Blossom protocol research, existing learnings
|
||||
|
||||
### Key Improvements
|
||||
1. Corrected `DesktopBlossomClient` — needs `ByteArray` overload (currently only accepts `File`)
|
||||
2. `IAccount` interface needs `sendNip17EncryptedFile()` added (not just `DesktopIAccount`)
|
||||
3. `NIP17Factory.createEncryptedFileNIP17()` already exists — plan incorrectly referenced `createFileNIP17()`
|
||||
4. `DesktopMediaMetadata.compute()` reads file bytes internally — encrypted upload must avoid double-read
|
||||
5. Added streaming encryption consideration for large files and memory pressure mitigation
|
||||
|
||||
### Critical Corrections from Source Code
|
||||
- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` parameter — encrypted size, not original
|
||||
- `DesktopBlossomClient.upload()` only accepts `File`, not `ByteArray` — needs overload or temp file
|
||||
- `ChatMessageEncryptedFileHeaderEvent.build()` takes `cipher: AESGCM` directly — no manual key/nonce extraction needed
|
||||
- `key()` and `nonce()` return tag values as parsed types (via `EncryptionKey`/`EncryptionNonce` tags)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement full send + receive encrypted media support in desktop DM chat. Users can attach files via paperclip button or drag-and-drop, which get AES-GCM encrypted before upload to Blossom. Files are sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap (NIP-59). Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay.
|
||||
|
||||
This is Phase 6 of the desktop media parity plan.
|
||||
|
||||
## Problem Statement / Motivation
|
||||
|
||||
Desktop DM chat (`ChatPane.kt`) supports text messages but has no file attachment capability. Android has a complete implementation (`ChatFileUploader` + `ChatFileSender`). Most protocol and service pieces exist on desktop already — this work wires them together with a desktop-native UX.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol layer (quartz) and extending desktop services. Three workstreams: upload pipeline, send logic, and receive/display.
|
||||
|
||||
(see brainstorm: `docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md`)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A: Encrypted Upload Pipeline
|
||||
|
||||
**Goal:** `DesktopUploadOrchestrator` gains `uploadEncrypted()` method.
|
||||
|
||||
**Files:**
|
||||
- `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` — add `uploadEncrypted()`
|
||||
- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — add `ByteArray` upload overload
|
||||
- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — no changes needed (already takes hash + size)
|
||||
- `desktopApp/.../service/upload/DesktopMediaMetadata.kt` — no changes needed
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```kotlin
|
||||
// DesktopUploadOrchestrator.kt — new method
|
||||
suspend fun uploadEncrypted(
|
||||
file: File,
|
||||
cipher: AESGCM,
|
||||
serverBaseUrl: String,
|
||||
signer: NostrSigner,
|
||||
): EncryptedUploadResult {
|
||||
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
|
||||
// NOTE: DesktopMediaMetadata.compute() reads file bytes internally for SHA256
|
||||
val metadata = DesktopMediaMetadata.compute(file)
|
||||
|
||||
// 2. Read file bytes and encrypt
|
||||
val plaintext = file.readBytes()
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
|
||||
// 3. Compute SHA256 of ENCRYPTED blob (critical: not plaintext)
|
||||
val encryptedHash = sha256(encrypted).toHexKey()
|
||||
val encryptedSize = encrypted.size.toLong()
|
||||
|
||||
// 4. Create Blossom auth with ENCRYPTED hash and size
|
||||
val authHeader = DesktopBlossomAuth.createUploadAuth(
|
||||
hash = encryptedHash,
|
||||
size = encryptedSize,
|
||||
alt = "Encrypted upload",
|
||||
signer = signer,
|
||||
)
|
||||
|
||||
// 5. Upload encrypted blob (needs ByteArray overload on client)
|
||||
val result = client.upload(
|
||||
bytes = encrypted,
|
||||
contentType = "application/octet-stream", // encrypted blob, not original mime
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
|
||||
return EncryptedUploadResult(
|
||||
blossom = result,
|
||||
metadata = metadata, // pre-encryption metadata (dimensions, blurhash, mime)
|
||||
encryptedHash = encryptedHash,
|
||||
encryptedSize = encryptedSize.toInt(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// New data class alongside existing UploadResult
|
||||
data class EncryptedUploadResult(
|
||||
val blossom: BlossomUploadResult,
|
||||
val metadata: MediaMetadata, // original file metadata
|
||||
val encryptedHash: String, // SHA256 of encrypted blob
|
||||
val encryptedSize: Int, // size of encrypted blob
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// DesktopBlossomClient.kt — add ByteArray overload
|
||||
suspend fun upload(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult = withContext(Dispatchers.IO) {
|
||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||
val requestBody = bytes.toRequestBody(contentType.toMediaType())
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
val reason = it.headers["X-Reason"] ?: it.code.toString()
|
||||
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights — Phase A
|
||||
|
||||
**Critical gotchas (from Blossom protocol research + source code analysis):**
|
||||
|
||||
| Issue | Detail | Solution |
|
||||
|-------|--------|----------|
|
||||
| Hash mismatch | `X-SHA-256` header must match encrypted blob hash, not plaintext | Compute SHA256 after `cipher.encrypt()` |
|
||||
| Content-Type | Upload encrypted blob as `application/octet-stream`, not original MIME | Server stores opaque blob |
|
||||
| Auth size | `DesktopBlossomAuth.createUploadAuth(size=)` must be encrypted size | Pass `encrypted.size.toLong()` |
|
||||
| Double file read | `DesktopMediaMetadata.compute()` calls `file.readBytes()` internally | Acceptable — metadata computation is separate from encryption read |
|
||||
| Memory pressure | `file.readBytes()` + `cipher.encrypt()` = 2x file size in memory | For files <50MB this is fine; for larger files consider streaming (future) |
|
||||
|
||||
**Security considerations:**
|
||||
- Generate fresh `AESGCM()` per file — never reuse key/nonce pairs (AES-GCM nonce reuse completely breaks confidentiality)
|
||||
- Clear `plaintext` ByteArray after encryption (`plaintext.fill(0)`) to minimize exposure window
|
||||
- Encrypted blob content type should be `application/octet-stream` to avoid leaking file type to Blossom server
|
||||
|
||||
---
|
||||
|
||||
### Phase B: DM File Attach UI in ChatPane
|
||||
|
||||
**Goal:** Paperclip button, thumbnail row, drag-and-drop in `ChatPane.kt`.
|
||||
|
||||
**Files:**
|
||||
- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable and `ChatPane()` for drag-drop
|
||||
|
||||
**Implementation:**
|
||||
|
||||
Modify `MessageInput()` (currently lines 527-627) — add parameters and UI elements:
|
||||
|
||||
```kotlin
|
||||
// Updated MessageInput signature
|
||||
@Composable
|
||||
private fun MessageInput(
|
||||
messageText: String,
|
||||
isNip17: Boolean,
|
||||
requiresNip17: Boolean,
|
||||
canSend: Boolean,
|
||||
attachedFiles: List<File>, // NEW
|
||||
onMessageChange: (String) -> Unit,
|
||||
onToggleNip17: () -> Unit,
|
||||
onAttachFiles: (List<File>) -> Unit, // NEW
|
||||
onRemoveFile: (Int) -> Unit, // NEW
|
||||
onSend: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
// Attachment thumbnail row (above text input)
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
AttachmentRow(
|
||||
files = attachedFiles,
|
||||
isEncrypted = isNip17,
|
||||
onRemove = onRemoveFile,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Paperclip attach button (only in NIP-17 mode)
|
||||
if (isNip17) {
|
||||
IconButton(
|
||||
onClick = { /* open JFileChooser */ },
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach file",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing OutlinedTextField...
|
||||
// Existing Send button...
|
||||
}
|
||||
|
||||
// Existing NIP-17 indicator...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// AttachmentRow composable
|
||||
@Composable
|
||||
private fun AttachmentRow(
|
||||
files: List<File>,
|
||||
isEncrypted: Boolean,
|
||||
onRemove: (Int) -> Unit,
|
||||
) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
) {
|
||||
itemsIndexed(files) { index, file ->
|
||||
Box(modifier = Modifier.size(64.dp)) {
|
||||
// Thumbnail (image preview or file icon)
|
||||
AttachmentThumbnail(file)
|
||||
|
||||
// Remove button (top-right)
|
||||
IconButton(
|
||||
onClick = { onRemove(index) },
|
||||
modifier = Modifier.align(Alignment.TopEnd).size(18.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Remove", Modifier.size(12.dp))
|
||||
}
|
||||
|
||||
// Lock icon overlay (bottom-end, only when encrypted)
|
||||
if (isEncrypted) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "Encrypted",
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||
RoundedCornerShape(4.dp),
|
||||
)
|
||||
.padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File picker (JFileChooser pattern from ComposeNoteDialog):**
|
||||
|
||||
```kotlin
|
||||
// File picker helper — runs on AWT thread
|
||||
private fun openFilePicker(onFilesSelected: (List<File>) -> Unit) {
|
||||
val chooser = JFileChooser().apply {
|
||||
isMultiSelectionEnabled = true
|
||||
fileFilter = FileNameExtensionFilter(
|
||||
"Media files",
|
||||
"jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov",
|
||||
"mp3", "ogg", "wav", "flac", "aac",
|
||||
)
|
||||
}
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
onFilesSelected(chooser.selectedFiles.toList())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Drag-and-drop on ChatPane (wrapping the Column):**
|
||||
|
||||
```kotlin
|
||||
// In ChatPane() — wrap the main Column with drag-drop
|
||||
var isDragOver by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onExternalDrag(
|
||||
onDragStart = { isDragOver = true },
|
||||
onDragExit = { isDragOver = false },
|
||||
onDrop = { state ->
|
||||
isDragOver = false
|
||||
val files = state.dragData
|
||||
.let { it as? DragData.FilesList }
|
||||
?.readFiles()
|
||||
?.mapNotNull { uri -> File(URI(uri)) }
|
||||
?: emptyList()
|
||||
// Add to attachedFiles state
|
||||
attachedFiles.addAll(files)
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (isDragOver) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
) {
|
||||
// ... existing ChatPane content
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights — Phase B
|
||||
|
||||
**Compose best practices applied:**
|
||||
|
||||
| Pattern | Recommendation | Rationale |
|
||||
|---------|---------------|-----------|
|
||||
| State location | `attachedFiles` as `mutableStateListOf<File>()` in `ChatPane`, not `MessageInput` | State hoisted to parent that also handles send/upload logic |
|
||||
| File picker thread | `JFileChooser` must run on EDT; use `withContext(Dispatchers.Main)` or `SwingUtilities.invokeLater` | AWT file dialogs block; Compose coroutines must not be blocked |
|
||||
| Drag-drop modifier | `Modifier.onExternalDrag` from `compose.ui` | Already used in ComposeNoteDialog — consistent pattern |
|
||||
| Thumbnail rendering | Use `ImageIO.read()` for image thumbnails; generic icon for audio/video | Avoid loading full-resolution images; scale down for 64dp thumbnails |
|
||||
| `canSend` update | `canSend` should also be true when `attachedFiles.isNotEmpty()` even if `messageText.isEmpty()` | Allow sending file-only messages (no text required) |
|
||||
|
||||
**Desktop UX considerations:**
|
||||
- Keyboard shortcut: Cmd+V / Ctrl+V paste should also add clipboard images to attachments (future enhancement)
|
||||
- Tooltip on disabled attach button (NIP-04 mode): "Switch to NIP-17 to send files"
|
||||
- Maximum attachment count: limit to 10 files to prevent UI overflow
|
||||
- File size validation: warn on files >50MB before attempting upload
|
||||
|
||||
---
|
||||
|
||||
### Phase C: Send Encrypted File Event
|
||||
|
||||
**Goal:** Build and dispatch `ChatMessageEncryptedFileHeaderEvent` (kind 15) from upload results.
|
||||
|
||||
**Files:**
|
||||
- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `ChatPane()` scope
|
||||
- `desktopApp/.../model/DesktopIAccount.kt` — add `sendNip17EncryptedFile()`
|
||||
- `commons/.../model/IAccount.kt` — add interface method
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```kotlin
|
||||
// IAccount.kt — add to interface (commons/commonMain)
|
||||
/** Send a NIP-17 gift-wrapped encrypted file header */
|
||||
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly)
|
||||
override suspend fun sendNip17EncryptedFile(
|
||||
template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
|
||||
// Optimistic local add — use the inner event
|
||||
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
|
||||
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
|
||||
|
||||
// Collect wraps with target relays and send
|
||||
val batch = result.wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays = if (recipientKey != null) {
|
||||
val dmRelays = localCache.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelays()?.toSet()
|
||||
dmRelays?.ifEmpty { null } ?: relayManager.connectedRelays.value
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ChatPane.kt — send handler for encrypted files
|
||||
// In ChatPane composable scope, after upload completes:
|
||||
private suspend fun sendEncryptedFiles(
|
||||
uploads: List<Pair<EncryptedUploadResult, AESGCM>>,
|
||||
roomKey: ChatroomKey,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
) {
|
||||
val recipients = roomKey.users.map { cacheProvider.getOrCreateUser(it).toPTag() }
|
||||
|
||||
for ((result, cipher) in uploads) {
|
||||
val template = ChatMessageEncryptedFileHeaderEvent.build(
|
||||
to = recipients,
|
||||
url = result.blossom.url,
|
||||
cipher = cipher, // passes algo, key, nonce automatically
|
||||
mimeType = result.metadata.mimeType,
|
||||
hash = result.encryptedHash, // hash of encrypted blob
|
||||
size = result.encryptedSize,
|
||||
dimension = result.metadata.width?.let { w ->
|
||||
result.metadata.height?.let { h -> DimensionTag(w, h) }
|
||||
},
|
||||
blurhash = result.metadata.blurhash,
|
||||
originalHash = result.metadata.sha256, // hash of original plaintext
|
||||
)
|
||||
account.sendNip17EncryptedFile(template)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Full send flow in ChatPane (upload + send):**
|
||||
|
||||
```kotlin
|
||||
// In ChatPane composable — triggered by Send button when attachedFiles.isNotEmpty()
|
||||
scope.launch {
|
||||
val orchestrator = DesktopUploadOrchestrator()
|
||||
val server = /* user's default Blossom server from kind 10063 */
|
||||
val uploads = mutableListOf<Pair<EncryptedUploadResult, AESGCM>>()
|
||||
|
||||
for (file in attachedFiles) {
|
||||
val cipher = AESGCM() // fresh cipher per file
|
||||
try {
|
||||
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
|
||||
uploads.add(result to cipher)
|
||||
} catch (e: Exception) {
|
||||
// Show error, keep remaining files for retry
|
||||
println("Upload failed for ${file.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (uploads.isNotEmpty()) {
|
||||
sendEncryptedFiles(uploads, roomKey, account, cacheProvider)
|
||||
attachedFiles.clear()
|
||||
|
||||
// Also send text message if present
|
||||
if (messageState.canSend) {
|
||||
messageState.send()
|
||||
messageState.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights — Phase C
|
||||
|
||||
**Correctness checks from source code:**
|
||||
|
||||
| Verified | Detail |
|
||||
|----------|--------|
|
||||
| `NIP17Factory.createEncryptedFileNIP17()` | Exists at `NIP17Factory.kt:86-97` — takes `EventTemplate<ChatMessageEncryptedFileHeaderEvent>` |
|
||||
| `ChatMessageEncryptedFileHeaderEvent.build()` | Takes `cipher: AESGCM` directly — auto-extracts algo/key/nonce via `encryptionAlgo(cipher.name())`, `encryptionKey(cipher.keyBytes)`, `encryptionNonce(cipher.nonce)` |
|
||||
| Android pattern | `Account.sendNip17EncryptedFile()` at line 1612 calls `NIP17Factory().createEncryptedFileNIP17(template, signer)` then `broadcastPrivately(wraps)` |
|
||||
| Desktop pattern | `DesktopIAccount.sendNip17PrivateMessage()` at line 128 — same structure, replace `createMessageNIP17` with `createEncryptedFileNIP17` |
|
||||
|
||||
**Concurrency considerations:**
|
||||
- Upload files sequentially (not parallel) to avoid memory pressure from multiple concurrent encryptions
|
||||
- Use `supervisorScope` if you want one failed upload to not cancel others
|
||||
- Send events can be parallelized (each is independent after upload)
|
||||
|
||||
**Interface change impact:**
|
||||
- Adding `sendNip17EncryptedFile` to `IAccount` requires implementation in Android's `Account.kt` too — but it already has it as a non-override method. Just add `override` keyword.
|
||||
|
||||
---
|
||||
|
||||
### Phase D: Receive & Display Encrypted Media
|
||||
|
||||
**Goal:** Encrypted media in received DMs renders inline with lock icon.
|
||||
|
||||
**Files:**
|
||||
- `desktopApp/.../ui/chats/ChatPane.kt` — enhance `ChatFileAttachment()` (line 442)
|
||||
- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists, enhance with caching
|
||||
|
||||
**Current state:** `ChatFileAttachment` is called at line 442 but its implementation is minimal or placeholder. The event is already detected as `ChatMessageEncryptedFileHeaderEvent`.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun ChatFileAttachment(event: ChatMessageEncryptedFileHeaderEvent) {
|
||||
// Parse cipher params from event tags
|
||||
val url = event.url()
|
||||
val keyBytes = event.key() // returns ByteArray? (parsed from EncryptionKey tag)
|
||||
val nonceBytes = event.nonce() // returns ByteArray? (parsed from EncryptionNonce tag)
|
||||
val mimeType = event.mimeType()
|
||||
val blurhashStr = event.blurhash()
|
||||
|
||||
if (url.isNullOrEmpty() || keyBytes == null || nonceBytes == null) {
|
||||
// Missing encryption params — show error
|
||||
EncryptedFileError("Missing encryption data")
|
||||
return
|
||||
}
|
||||
|
||||
// Async download + decrypt with proper state management
|
||||
var decryptionState by remember(event.id) {
|
||||
mutableStateOf<DecryptionState>(DecryptionState.Loading)
|
||||
}
|
||||
|
||||
LaunchedEffect(event.id) {
|
||||
decryptionState = try {
|
||||
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonceBytes)
|
||||
DecryptionState.Success(bytes)
|
||||
} catch (e: Exception) {
|
||||
DecryptionState.Error(e.message ?: "Decryption failed")
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
when (val state = decryptionState) {
|
||||
is DecryptionState.Loading -> {
|
||||
// Blurhash placeholder or shimmer
|
||||
if (blurhashStr != null) {
|
||||
BlurhashPlaceholder(
|
||||
blurhash = blurhashStr,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DecryptionState.Success -> {
|
||||
DecryptedMediaContent(
|
||||
bytes = state.bytes,
|
||||
mimeType = mimeType,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
is DecryptionState.Error -> {
|
||||
EncryptedFileError(state.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Lock icon overlay (always visible)
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "End-to-end encrypted",
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||
RoundedCornerShape(4.dp),
|
||||
)
|
||||
.padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DecryptionState {
|
||||
data object Loading : DecryptionState()
|
||||
data class Success(val bytes: ByteArray) : DecryptionState()
|
||||
data class Error(val message: String) : DecryptionState()
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// DecryptedMediaContent — render based on MIME type
|
||||
@Composable
|
||||
private fun DecryptedMediaContent(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
mimeType?.startsWith("image/") == true -> {
|
||||
val bitmap = remember(bytes) {
|
||||
org.jetbrains.skia.Image.makeFromEncoded(bytes)
|
||||
.toComposeImageBitmap()
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "Encrypted image",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
mimeType?.startsWith("video/") == true -> {
|
||||
// Show video thumbnail or play button
|
||||
// Full video playback requires writing decrypted bytes to temp file for VLC
|
||||
VideoFilePlaceholder(bytes.size, modifier)
|
||||
}
|
||||
mimeType?.startsWith("audio/") == true -> {
|
||||
AudioFilePlaceholder(bytes.size, modifier)
|
||||
}
|
||||
else -> {
|
||||
GenericFilePlaceholder(mimeType, bytes.size, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights — Phase D
|
||||
|
||||
**Compose state management:**
|
||||
- Use `LaunchedEffect(event.id)` not `produceState` — better control over loading/error states via sealed class
|
||||
- `remember(event.id)` keys state to the event, preventing re-download on recomposition
|
||||
- `remember(bytes)` for Skia bitmap conversion prevents recreating bitmap every recomposition
|
||||
|
||||
**Performance considerations:**
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---------|------------|
|
||||
| Re-download on scroll | Add in-memory LRU cache to `EncryptedMediaService` keyed by URL |
|
||||
| Large decrypted images in memory | Scale down to max display size (300dp) before caching |
|
||||
| Bitmap creation from bytes | `org.jetbrains.skia.Image.makeFromEncoded()` is efficient for JVM |
|
||||
| Video/audio playback | Requires writing decrypted bytes to temp file (VLC needs file path). Use `File.createTempFile()` with `.deleteOnExit()` |
|
||||
|
||||
**Add caching to EncryptedMediaService:**
|
||||
|
||||
```kotlin
|
||||
object EncryptedMediaService {
|
||||
private val httpClient = OkHttpClient()
|
||||
private val cache = LruCache<String, ByteArray>(maxSize = 20) // ~20 decrypted files
|
||||
|
||||
suspend fun downloadAndDecrypt(
|
||||
url: String,
|
||||
keyBytes: ByteArray,
|
||||
nonce: ByteArray,
|
||||
): ByteArray {
|
||||
cache.get(url)?.let { return it }
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val encryptedBytes = response.use {
|
||||
if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}")
|
||||
it.body.bytes()
|
||||
}
|
||||
|
||||
val cipher = AESGCM(keyBytes, nonce)
|
||||
val decrypted = cipher.decrypt(encryptedBytes)
|
||||
cache.put(url, decrypted)
|
||||
decrypted
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security note:** Cached decrypted bytes are in JVM heap memory. This is acceptable for a desktop app (no shared memory concerns). Consider cache eviction on app minimize if paranoid.
|
||||
|
||||
---
|
||||
|
||||
### Phase E: Error Handling & Edge Cases
|
||||
|
||||
**Files:** Across all modified files above.
|
||||
|
||||
| Scenario | Handling | Implementation |
|
||||
|----------|----------|----------------|
|
||||
| Upload fails mid-way | Show error snackbar, keep file in attachment row for retry | Catch in upload loop, skip failed file, continue others |
|
||||
| Network disconnect during upload | Catch `IOException`, show "Upload failed — check connection" | OkHttp throws on network failure |
|
||||
| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | `DecryptionState.Error` sealed class variant |
|
||||
| Blossom server unreachable | Fallback to next server in user's kind 10063 list | Query `BlossomServersEvent` for alternatives |
|
||||
| Large file (>10MB) | Show progress indicator during encrypt + upload | Extend `DesktopUploadTracker` for encrypted uploads |
|
||||
| Unsupported MIME type | Show generic file icon with filename + size | `GenericFilePlaceholder` composable |
|
||||
| No Blossom servers configured | Disable attach button, show tooltip | Check server list before enabling button |
|
||||
| NIP-04 mode active | Attach button hidden (encrypted files are NIP-17 only) | `if (isNip17)` guard on paperclip button |
|
||||
| Corrupt encrypted blob | `AESGCM.decrypt()` throws `AEADBadTagException` | Catch specifically, show "File corrupted or tampered" |
|
||||
| Duplicate upload (same file) | Each send generates new cipher — different encrypted blob | Intentional: no deduplication for privacy |
|
||||
| Rapid send taps | Disable send button during upload/send | `isUploading` state flag |
|
||||
|
||||
### Research Insights — Phase E
|
||||
|
||||
**Error hierarchy (from AESGCM source):**
|
||||
- `AESGCM.decrypt()` uses JCE `Cipher` on JVM — throws `AEADBadTagException` for wrong key (not generic exception)
|
||||
- `AESGCM.decryptOrNull()` exists — returns null instead of throwing. Prefer this for UI code.
|
||||
|
||||
**Security edge cases:**
|
||||
- Never log encryption keys, nonces, or decrypted content
|
||||
- Temp files for video playback must be deleted after use (`deleteOnExit()` + explicit delete on composable disposal)
|
||||
- Don't show detailed error messages that could leak cipher state ("wrong key" is fine, "key was X but expected Y" is not)
|
||||
|
||||
---
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Security
|
||||
- New `AESGCM()` cipher per file (random key + nonce) — never reuse
|
||||
- Encrypt before hashing — Blossom auth scoped to encrypted blob
|
||||
- Plaintext never leaves device unencrypted
|
||||
- GiftWrap ensures only recipients can see the kind 15 event
|
||||
- Zero `plaintext` ByteArray after encryption to minimize exposure window
|
||||
- Upload content type is `application/octet-stream` (doesn't leak file type to server)
|
||||
- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay attacks
|
||||
|
||||
### Performance
|
||||
- Encryption runs on `Dispatchers.IO` (non-blocking)
|
||||
- Sequential file upload (not parallel) to limit memory to 2x single file size
|
||||
- In-memory LRU cache for decrypted media (20 entries) avoids re-downloading
|
||||
- `org.jetbrains.skia.Image.makeFromEncoded()` for efficient bitmap creation
|
||||
- Large files (>50MB): warn user before upload; consider streaming in future
|
||||
|
||||
### Architecture
|
||||
- No new modules — extends existing desktop services
|
||||
- Protocol layer (quartz) unchanged — fully reuses existing events/ciphers
|
||||
- Follows Android patterns for consistency across platforms
|
||||
- `IAccount` interface gains one new method; Android already has implementation (add `override`)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Paperclip attach button visible in DM chat input (NIP-17 mode only)
|
||||
- [x] File picker opens, supports image/video/audio selection
|
||||
- [x] Selected files show as thumbnails above input with X to remove
|
||||
- [x] Drag-and-drop files onto chat area adds to attachments (visual drop indicator)
|
||||
- [x] Send encrypts files with AES-GCM before upload to Blossom
|
||||
- [x] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap
|
||||
- [x] Lock icon overlay visible on attachment thumbnails before send
|
||||
- [x] Received encrypted media downloads, decrypts, displays inline
|
||||
- [x] Lock icon overlay on received encrypted media in chat bubbles
|
||||
- [x] Wrong key / failed decryption shows error state, no crash
|
||||
- [x] Upload progress indicator during encrypt + upload
|
||||
- [x] No attach button when in NIP-04 mode
|
||||
- [x] `canSend` true when files attached (even without text)
|
||||
- [x] Send button disabled during active upload
|
||||
|
||||
## Test Plan (from Phase 6 testing plan)
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail appears above input with lock indicator |
|
||||
| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap sent |
|
||||
| 6.3 | Receive encrypted | Receive DM with encrypted file (from Android) | File downloads, decrypts, displays in bubble with lock icon |
|
||||
| 6.4 | Wrong key | View encrypted media where key doesn't match | "Could not decrypt" placeholder, no crash |
|
||||
| 6.5 | Drag-drop attach | Drag image onto DM chat | File appears in attachment row with lock icon |
|
||||
| 6.6 | Multiple files | Attach 3 files → send | All 3 upload encrypted, each gets own kind 15 event |
|
||||
| 6.7 | Large file | Attach 15MB image → send | Progress shown, upload succeeds |
|
||||
| 6.8 | NIP-04 mode | Toggle to NIP-04 → check attach button | Attach button hidden/disabled |
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Dependency | Risk | Mitigation |
|
||||
|-----------|------|------------|
|
||||
| Blossom server availability | Upload fails | Retry + fallback to alternate servers from kind 10063 |
|
||||
| VLC for video playback | Encrypted video won't play without VLC | Show "Download" button for video; image display works regardless |
|
||||
| Android client for cross-platform test | Can't verify interop | Use Android emulator or second account |
|
||||
| `DesktopBlossomClient` ByteArray overload | New method needed | Simple addition — uses OkHttp `ByteArray.toRequestBody()` |
|
||||
| `IAccount` interface change | Requires Android-side `override` keyword | Android already has the method, just not `override` |
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
- **Brainstorm document:** [docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md](docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md) — Key decisions: inline attach button UX, drag-drop support, lock icon overlay, full send+receive scope
|
||||
|
||||
### Internal References (verified from source code)
|
||||
- Android `ChatFileUploader.justUploadNIP17()`: `amethyst/.../upload/ChatFileUploader.kt:39-80`
|
||||
- Android `ChatFileSender.sendNIP17()`: `amethyst/.../upload/ChatFileSender.kt:46-71`
|
||||
- Android `Account.sendNip17EncryptedFile()`: `amethyst/.../model/Account.kt:1612-1617`
|
||||
- Desktop `ChatPane.kt`: `desktopApp/.../ui/chats/ChatPane.kt` (628 lines)
|
||||
- Desktop `DesktopUploadOrchestrator`: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` (78 lines)
|
||||
- Desktop `DesktopBlossomClient`: `desktopApp/.../service/upload/DesktopBlossomClient.kt` (74 lines)
|
||||
- Desktop `DesktopBlossomAuth`: `desktopApp/.../service/upload/DesktopBlossomAuth.kt` (43 lines)
|
||||
- Desktop `DesktopMediaMetadata`: `desktopApp/.../service/upload/DesktopMediaMetadata.kt` (89 lines)
|
||||
- Desktop `EncryptedMediaService`: `desktopApp/.../service/media/EncryptedMediaService.kt` (57 lines)
|
||||
- Desktop `DesktopIAccount`: `desktopApp/.../model/DesktopIAccount.kt`
|
||||
- Desktop `ComposeNoteDialog` (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt`
|
||||
- Commons `IAccount` interface: `commons/.../model/IAccount.kt:106-113`
|
||||
- Quartz `ChatMessageEncryptedFileHeaderEvent.build()`: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt:80-110`
|
||||
- Quartz `NIP17Factory.createEncryptedFileNIP17()`: `quartz/.../nip17Dm/NIP17Factory.kt:86-97`
|
||||
- Quartz `AESGCM`: `quartz/.../utils/ciphers/AESGCM.kt`
|
||||
- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md`
|
||||
|
||||
### Gotchas (from learnings research + source verification)
|
||||
- Hash encrypted blob, not plaintext, for Blossom auth `x` tag and `X-SHA-256` header
|
||||
- New AESGCM cipher per file — never reuse key/nonce pair (nonce reuse breaks AES-GCM completely)
|
||||
- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay
|
||||
- NIP-04 does not support encrypted file headers — hide attach button in NIP-04 mode
|
||||
- `DesktopBlossomClient.upload()` only accepts `File` — needs `ByteArray` overload for encrypted blobs
|
||||
- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` — must be encrypted blob size
|
||||
- Content-Type for encrypted upload must be `application/octet-stream`, not original MIME
|
||||
- `AESGCM.decryptOrNull()` exists — prefer over `decrypt()` for UI code (no exception on wrong key)
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
title: "feat: Stacked messages layout in deck columns"
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-03-19
|
||||
deepened: 2026-03-19
|
||||
origin: docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Stacked Messages Layout in Deck Columns
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-19
|
||||
**Files to change:** 4
|
||||
**Approach:** Add `compactMode` flag, conditional layout in `DesktopMessagesScreen`
|
||||
|
||||
### Key Implementation Details
|
||||
1. `ConversationListPane` width is hardcoded at line 122 (`Modifier.width(280.dp)`) — remove it, let caller control width
|
||||
2. `ChatPane` header (lines 211-231) uses `ChatroomHeader`/`GroupChatroomHeader` — wrap with `Row` adding back arrow
|
||||
3. `DesktopMessagesScreen` already has `selectedRoom` state and `clearSelection()` — stacked nav is pure layout change
|
||||
4. Keyboard Escape handling already at line 102 calls `listState.clearSelection()` — works as-is for back nav
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the side-by-side split-pane Messages layout with stacked navigation in deck columns. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the current split layout.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
In multi-deck mode, columns are 350-400dp wide. The current layout allocates 280dp to `ConversationListPane` (line 122) and the remaining 70-120dp to `ChatPane` — unusable.
|
||||
|
||||
(see brainstorm: `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Make ConversationListPane width flexible
|
||||
|
||||
**File:** `desktopApp/.../ui/chats/ConversationListPane.kt` (line 119-123)
|
||||
|
||||
**Current code:**
|
||||
```kotlin
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(280.dp) // ← hardcoded, breaks compact mode
|
||||
.fillMaxHeight()
|
||||
```
|
||||
|
||||
**Change:** Remove the hardcoded width from the composable. The caller controls width via the `modifier` parameter.
|
||||
|
||||
```kotlin
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
```
|
||||
|
||||
**Call sites:**
|
||||
- Split mode (DesktopMessagesScreen): passes no modifier → add `Modifier.width(280.dp)` at call site
|
||||
- Compact mode: passes `Modifier.fillMaxWidth()` → uses full column width
|
||||
|
||||
### Research Insights
|
||||
|
||||
- `ConversationListPane` already accepts `modifier: Modifier = Modifier` (line 95) — just unused for width
|
||||
- The keyboard nav (`onPreviewKeyEvent` at line 126) and `LazyColumn` (line 243) work at any width
|
||||
- `ConversationCard` (line 268) uses `Modifier.fillMaxWidth()` — adapts automatically
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Add `onBack` to ChatPane header
|
||||
|
||||
**File:** `desktopApp/.../ui/chats/ChatPane.kt` (lines 136-144, 211-231)
|
||||
|
||||
**Current signature:**
|
||||
```kotlin
|
||||
fun ChatPane(
|
||||
roomKey: ChatroomKey,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
feedViewModel: ChatroomFeedViewModel,
|
||||
messageState: ChatNewMessageState,
|
||||
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
```
|
||||
|
||||
**Add:** `onBack: (() -> Unit)? = null` parameter.
|
||||
|
||||
**Current header (lines 211-231):**
|
||||
```kotlin
|
||||
// Header
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
users = users,
|
||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||
)
|
||||
} else {
|
||||
users.firstOrNull()?.let { user ->
|
||||
ChatroomHeader(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
} ?: run {
|
||||
Text(
|
||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New header:** Wrap in a `Row` with conditional back arrow:
|
||||
|
||||
```kotlin
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to conversations",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Existing header content (ChatroomHeader / GroupChatroomHeader / fallback)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights
|
||||
|
||||
- `Icons.AutoMirrored.Filled.ArrowBack` is already available in material-icons-extended
|
||||
- `ChatroomHeader` and `GroupChatroomHeader` are shared composables from commons — don't modify them
|
||||
- The `Row` wrapper doesn't affect the existing divider at line 233 (`HorizontalDivider()`)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Refactor DesktopMessagesScreen layout
|
||||
|
||||
**File:** `desktopApp/.../ui/chats/DesktopMessagesScreen.kt`
|
||||
|
||||
**Current:** Single `Row` layout (lines 92-168) with `ConversationListPane` + `VerticalDivider` + `ChatPane`/`EmptyState`.
|
||||
|
||||
**Change:** Add `compactMode: Boolean = false` parameter. Extract existing Row into a private `SplitMessagesContent` composable. Add a new `CompactMessagesContent` for stacked mode.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DesktopMessagesScreen(
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
compactMode: Boolean = false, // NEW
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = remember(account) {
|
||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||
}
|
||||
val selectedRoom by listState.selectedRoom.collectAsState()
|
||||
val listFocusRequester = remember { FocusRequester() }
|
||||
var showNewDmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Keyboard shortcuts (shared between modes)
|
||||
val keyHandler = Modifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
|
||||
when {
|
||||
event.key == Key.Escape -> {
|
||||
listState.clearSelection()
|
||||
true
|
||||
}
|
||||
event.key == Key.N && isModifier && event.isShiftPressed -> {
|
||||
showNewDmDialog = true
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
showNewDmDialog = showNewDmDialog,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
keyHandler = keyHandler,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
)
|
||||
}
|
||||
|
||||
// New DM dialog (shared)
|
||||
if (showNewDmDialog) { /* existing NewDmDialog code */ }
|
||||
}
|
||||
```
|
||||
|
||||
**CompactMessagesContent:**
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun CompactMessagesContent(
|
||||
selectedRoom: ChatroomKey?,
|
||||
listState: ChatroomListState,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
scope: CoroutineScope,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
showNewDmDialog: Boolean,
|
||||
onShowNewDm: () -> Unit,
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
val currentRoom = selectedRoom
|
||||
if (currentRoom != null) {
|
||||
// Full-width chat with back arrow
|
||||
val feedViewModel = remember(currentRoom) {
|
||||
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
|
||||
}
|
||||
val messageState = remember(currentRoom) {
|
||||
ChatNewMessageState(account, cacheProvider, scope)
|
||||
}
|
||||
val broadcastStatus = if (account is DesktopIAccount) {
|
||||
account.dmSendTracker.status.collectAsState().value
|
||||
} else DmBroadcastStatus.Idle
|
||||
|
||||
ChatPane(
|
||||
roomKey = currentRoom,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
feedViewModel = feedViewModel,
|
||||
messageState = messageState,
|
||||
dmBroadcastStatus = broadcastStatus,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onBack = { listState.clearSelection() },
|
||||
)
|
||||
} else {
|
||||
// Full-width contact list
|
||||
ConversationListPane(
|
||||
state = listState,
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SplitMessagesContent:** Extract existing `Row` code verbatim from current `DesktopMessagesScreen`, adding `Modifier.width(280.dp)` to the `ConversationListPane` call.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Wire compactMode from deck
|
||||
|
||||
**File:** `desktopApp/.../ui/deck/DeckColumnContainer.kt` (line 206-214)
|
||||
|
||||
```kotlin
|
||||
DeckColumnType.Messages -> {
|
||||
DesktopMessagesScreen(
|
||||
account = iAccount,
|
||||
cacheProvider = localCache,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
compactMode = true, // ← ADD THIS
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`SinglePaneLayout` — no change needed, `compactMode` defaults to `false`.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Scenario | Behavior | Verified by |
|
||||
|----------|----------|-------------|
|
||||
| Escape in chat | `clearSelection()` → back to list | Existing keyboard handler (line 102) |
|
||||
| Escape in list | No-op (already no selection) | Same handler, `clearSelection()` on null is safe |
|
||||
| New DM dialog in compact chat | Dialog opens over chat, selecting user switches to that chat | `showNewDmDialog` is shared state |
|
||||
| Receiving DM while viewing list | Chatroom list updates via 2s polling | `ChatroomListState.refreshRooms()` |
|
||||
| Receiving DM while in chat | Messages appear real-time via `ChatroomFeedViewModel` | No change needed |
|
||||
| Back arrow + drag-drop | Drag-drop zone is on the ChatPane Column, unaffected by back arrow Row | Separate modifier chain |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Deck Messages column shows full-width contact list (no split)
|
||||
- [x] Clicking conversation navigates to full-width chat view
|
||||
- [x] Back arrow visible in chat header (compact mode only)
|
||||
- [x] Clicking back arrow returns to contact list
|
||||
- [x] Escape key still returns to contact list
|
||||
- [x] Single-pane mode unchanged (split layout preserved)
|
||||
- [x] Keyboard navigation (up/down/enter) still works in contact list
|
||||
- [x] New DM dialog still works in both modes
|
||||
- [x] ConversationListPane uses full width in compact mode
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change | Lines affected |
|
||||
|------|--------|---------------|
|
||||
| `ConversationListPane.kt` | Remove hardcoded `width(280.dp)` | Line 122 |
|
||||
| `ChatPane.kt` | Add `onBack` param, wrap header in Row with back arrow | Lines 136-144, 211-231 |
|
||||
| `DesktopMessagesScreen.kt` | Add `compactMode`, extract `SplitMessagesContent`/`CompactMessagesContent` | Major refactor |
|
||||
| `DeckColumnContainer.kt` | Pass `compactMode = true` | Line ~208 |
|
||||
|
||||
## Sources
|
||||
|
||||
- **Brainstorm:** `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`
|
||||
- `DesktopMessagesScreen.kt:75-213` — current split-pane layout
|
||||
- `ChatPane.kt:136-144` — current signature; `211-231` — header section
|
||||
- `ConversationListPane.kt:119-122` — hardcoded width; `95` — modifier param
|
||||
- `DeckColumnContainer.kt:206-214` — Messages deck routing
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user