From 1a2dc9efac3bdf14e9d736a28e268bbf3fd31714 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 10:56:26 +0200 Subject: [PATCH] feat(media): lightbox overlay with zoom, gallery navigation, and save Phase 5: Full-screen lightbox overlay with mouse wheel zoom + pan, left/right gallery carousel, keyboard shortcuts (Esc/arrows/Ctrl+S), save to disk via FileDialog. Images in NoteCard are clickable. Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/ui/FeedScreen.kt | 14 ++ .../desktop/ui/media/LightboxOverlay.kt | 205 ++++++++++++++++++ .../desktop/ui/media/SaveMediaAction.kt | 70 ++++++ .../desktop/ui/media/ZoomableImage.kt | 100 +++++++++ .../amethyst/desktop/ui/note/NoteCard.kt | 12 +- 5 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e539b1510..650ce53d5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -75,6 +75,7 @@ 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.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -100,6 +101,7 @@ fun FeedNoteCard( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onImageClick: ((List, Int) -> Unit)? = null, zapReceipts: List = emptyList(), reactionCount: Int = 0, replyCount: Int = 0, @@ -119,6 +121,7 @@ fun FeedNoteCard( NoteCard( note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, + onImageClick = onImageClick, ) // Action buttons (only if logged in) @@ -180,6 +183,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } + var lightboxState by remember { mutableStateOf, Int>?>(null) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } @@ -601,6 +605,7 @@ fun FeedScreen( onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> lightboxState = urls to index }, zapReceipts = zapsByEvent[event.id] ?: emptyList(), reactionCount = reactionsByEvent[event.id] ?: 0, replyCount = repliesByEvent[event.id] ?: 0, @@ -631,5 +636,14 @@ fun FeedScreen( replyTo = replyToEvent, ) } + + // Lightbox overlay + lightboxState?.let { (urls, index) -> + LightboxOverlay( + urls = urls, + initialIndex = index, + onDismiss = { lightboxState = null }, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt new file mode 100644 index 000000000..88c78ca5b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -0,0 +1,205 @@ +/* + * 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.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.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.Close +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@Composable +fun LightboxOverlay( + urls: List, + initialIndex: Int = 0, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) } + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.9f)) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Escape -> { + onDismiss() + true + } + + Key.DirectionLeft -> { + if (currentIndex > 0) currentIndex-- + true + } + + Key.DirectionRight -> { + if (currentIndex < urls.lastIndex) currentIndex++ + true + } + + Key.S -> { + if (event.isCtrlPressed) { + scope.launch { + SaveMediaAction.saveMedia(urls[currentIndex]) + } + true + } else { + false + } + } + + else -> { + false + } + } + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // Click on backdrop doesn't close — use X button or Esc + }, + ) { + // Main image + ZoomableImage( + url = urls[currentIndex], + modifier = Modifier.fillMaxSize().padding(48.dp), + ) + + // Top bar + Row( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (urls.size > 1) { + Text( + "${currentIndex + 1} / ${urls.size}", + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Row { + IconButton( + onClick = { + scope.launch { + SaveMediaAction.saveMedia(urls[currentIndex]) + } + }, + ) { + Icon( + Icons.Default.Save, + contentDescription = "Save", + tint = Color.White, + ) + } + + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White, + ) + } + } + } + + // Navigation arrows + if (urls.size > 1) { + 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), + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt new file mode 100644 index 000000000..15bb10afa --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -0,0 +1,70 @@ +/* + * 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, + ): File? = + withContext(Dispatchers.IO) { + val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" } + + val dialog = + FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply { + file = filename + } + dialog.isVisible = true + + val dir = dialog.directory ?: return@withContext null + val file = File(dir, dialog.file ?: return@withContext null) + + try { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + response.use { resp -> + if (!resp.isSuccessful) return@withContext null + resp.body.byteStream().use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + } + file + } catch (_: Exception) { + null + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt new file mode 100644 index 000000000..c80811d33 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -0,0 +1,100 @@ +/* + * 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.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, + onDoubleClick: (() -> Unit)? = null, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Box( + modifier = + modifier + .fillMaxSize() + .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, + ) { + AsyncImage( + model = url, + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ), + contentScale = ContentScale.Fit, + ) + } +} + +fun resetZoom(onReset: () -> Unit) { + onReset() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 4b526cdb4..d5a22d79a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -78,6 +78,7 @@ fun NoteCard( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, + onImageClick: ((List, Int) -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = @@ -173,7 +174,7 @@ fun NoteCard( if (strippedContent.isNotBlank()) { Spacer(Modifier.height(8.dp)) } - for (url in imageUrls) { + for ((index, url) in imageUrls.withIndex()) { AsyncImage( model = url, contentDescription = null, @@ -181,7 +182,14 @@ fun NoteCard( Modifier .fillMaxWidth() .heightIn(max = 400.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(RoundedCornerShape(8.dp)) + .then( + if (onImageClick != null) { + Modifier.clickable { onImageClick(imageUrls, index) } + } else { + Modifier + }, + ), contentScale = ContentScale.FillWidth, ) if (url != imageUrls.last()) {